Table of Contents
- A Boy Who Wanted To Create Worlds
- Why Ruby?
- What You Should Know Before Reading This Book
- What Are We Going To Build?
- Preparing The Tools
- Getting The Sample Code
- Other Tools
- Gosu Basics
- Warming Up
- Prototyping The Game
- Optimizing Game Performance
- Refactoring The Prototype
- Simulating Physics
- Implementing Health And Damage
- Creating Artificial Intelligence
-
Making The Prototype Playable
- Drawing Water Beyond Map Boundaries
- Generating Tree Clusters
- Generating Random Objects
- Implementing A Radar
- Dynamic Sound Volume And Panning
- Giving Enemies Identity
- Respawning Tanks And Removing Dead Ones
- Displaying Explosion Damage Trails
- Debugging Bullet Physics
- Making Camera Look Ahead
- Reviewing The Changes
- Dealing With Thousands Of Game Objects
- Implementing Powerups
- Implementing Heads Up Display
- Implementing Game Statistics
- Building Advanced AI
- Wrapping It Up
- Special Thanks
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.
Preparing The Tools
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
Other Tools
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 thanExplosion::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 theircurrent_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
Utils
.
button_down?
(
right
)
44
elsif
Utils
.
button_down?
(
down
)
45
angle
=
180
.
0
46
angle
-=
45
.
0
if
Utils
.
button_down?
(
left
)
47
angle
+=
45
.
0
if
Utils
.
button_down?
(
right
)
48
elsif
Utils
.
button_down?
(
left
)
49
angle
=
90
.
0
50
angle
+=
45
.
0
if
Utils
.
button_down?
(
up
)
51
angle
-=
45
.
0
if
Utils
.
button_down?
(
down
)
52
elsif
Utils
.
button_down?
(
right
)
53
angle
=
270
.
0
54
angle
-=
45
.
0
if
Utils
.
button_down?
(
up
)
55
angle
+=
45
.
0
if
Utils
.
button_down?
(
down
)
56
end
57
angle
=
(
angle
+
360
)
%
360
if
angle
&&
angle
<
0
58
(
angle
||
previous_angle
)
59
end
60
end
We finally come to a place where keyboard and mouse input is handled and converted to Tank
commands. We could have used Command
pattern
to decouple everything even further.
Refactoring PlayState
05-refactor/game_states/play_state.rb
1
require
'ruby-prof'
if
ENV
[
'ENABLE_PROFILING'
]
2
class
PlayState
<
GameState
3
attr_accessor
:update_interval
4
5
def
initialize
6
@map
=
Map
.
new
7
@camera
=
Camera
.
new
8
@object_pool
=
ObjectPool
.
new
(
@map
)
9
@tank
=
Tank
.
new
(
@object_pool
,
PlayerInput
.
new
(
@camera
))
10
@camera
.
target
=
@tank
11
end
12
13
def
enter
14
RubyProf
.
start
if
ENV
[
'ENABLE_PROFILING'
]
15
end
16
17
def
leave
18
if
ENV
[
'ENABLE_PROFILING'
]
19
result
=
RubyProf
.
stop
20
printer
=
RubyProf
::
FlatPrinter
.
new
(
result
)
21
printer
.
print
(
STDOUT
)
22
end
23
end
24
25
def
update
26
@object_pool
.
objects
.
map
(
&
:update
)
27
@object_pool
.
objects
.
reject!
(
&
:removable?
)
28
@camera
.
update
29
update_caption
30
end
31
32
def
draw
33
cam_x
=
@camera
.
x
34
cam_y
=
@camera
.
y
35
off_x
=
$window
.
width
/
2
-
cam_x
36
off_y
=
$window
.
height
/
2
-
cam_y
37
viewport
=
@camera
.
viewport
38
$window
.
translate
(
off_x
,
off_y
)
do
39
zoom
=
@camera
.
zoom
40
$window
.
scale
(
zoom
,
zoom
,
cam_x
,
cam_y
)
do
41
@map
.
draw
(
viewport
)
42
@object_pool
.
objects
.
map
{
|
o
|
o
.
draw
(
viewport
)
}
43
end
44
end
45
@camera
.
draw_crosshair
46
end
47
48
def
button_down
(
id
)
49
if
id
==
Gosu
::
KbQ
50
leave
51
$window
.
close
52
end
53
if
id
==
Gosu
::
KbEscape
54
GameState
.
switch
(
MenuState
.
instance
)
55
end
56
end
57
58
private
59
60
def
update_caption
61
now
=
Gosu
.
milliseconds
62
if
now
-
(
@caption_updated_at
||
0
)
>
1000
63
$window
.
caption
=
'Tanks Prototype. '
<<
64
"[FPS:
#{
Gosu
.
fps
}
. "
<<
65
"Tank @
#{
@tank
.
x
.
round
}
:
#{
@tank
.
y
.
round
}
]"
66
@caption_updated_at
=
now
67
end
68
end
69
end
Implementation of PlayState
is now also a little simpler. It doesn’t update @tank
or @bullets
individually anymore. Instead, it uses ObjectPool
and does all object operations in bulk.
Other Improvements
05-refactor/main.rb
1
#!/usr/bin/env ruby
2
3
require
'gosu'
4
5
root_dir
=
File
.
dirname
(
__FILE__
)
6
require_pattern
=
File
.
join
(
root_dir
,
'**/*.rb'
)
7
@failed
=
[]
8
9
# Dynamically require everything
10
Dir
.
glob
(
require_pattern
)
.
each
do
|
f
|
11
next
if
f
.
end_with?
(
'/main.rb'
)
12
begin
13
require_relative
f
.
gsub
(
"
#{
root_dir
}
/"
,
''
)
14
rescue
15
# May fail if parent class not required yet
16
@failed
<<
f
17
end
18
end
19
20
# Retry unresolved requires
21
@failed
.
each
do
|
f
|
22
require_relative
f
.
gsub
(
"
#{
root_dir
}
/"
,
''
)
23
end
24
25
$window
=
GameWindow
.
new
26
GameState
.
switch
(
MenuState
.
instance
)
27
$window
.
show
Finally, we made some improvements to main.rb
- it now recursively requires all *.rb
files
within same directory, so we don’t have to worry about it in other classes.
05-refactor/utils.rb
1
module
Utils
2
def
self
.
media_path
(
file
)
3
File
.
join
(
File
.
dirname
(
File
.
dirname
(
4
__FILE__
)),
'media'
,
file
)
5
end
6
7
def
self
.
track_update_interval
8
now
=
Gosu
.
milliseconds
9
@update_interval
=
(
now
-
(
@last_update
||=
0
))
.
to_f
10
@last_update
=
now
11
end
12
13
def
self
.
update_interval
14
@update_interval
||=
$window
.
update_interval
15
end
16
17
def
self
.
adjust_speed
(
speed
)
18
speed
*
update_interval
/
33
.
33
19
end
20
21
def
self
.
button_down?
(
button
)
22
@buttons
||=
{}
23
now
=
Gosu
.
milliseconds
24
now
=
now
-
(
now
%
150
)
25
if
$window
.
button_down?
(
button
)
26
@buttons
[
button
]
=
now
27
true
28
elsif
@buttons
[
button
]
29
if
now
==
@buttons
[
button
]
30
true
31
else
32
@buttons
.
delete
(
button
)
33
false
34
end
35
end
36
end
37
end
Another notable change is renaming Game
module into Utils
. The name finally makes more sense, I
have no idea why I put utility methods into Game
module in the first place. Also, Utils
received button_down?
method, that solves the issue of changing tank direction when button is
immediately released. It made very difficult to stop at diagonal angle, because when you depressed
two buttons, 16 ms was enough for Gosu to think “he released W, and S is still pressed, so let’s
change direction to S”. Utils#button_down?
gives a soft 150 ms window to synchronize button
release. Now controls feel more natural.
Simulating Physics
To make the game more realistic, we will spice things up with some physics. This is the feature set we are going to implement:
- Collision detection. Tank will bump into other objects - stationary tanks. Bullets will not go through them either.
- Terrain effects. Tank will go fast on grass, slower on sand.
Adding Enemy Objects
It’s boring to play alone, so we will make a quick change and spawn some stationary tanks that will
be deployed randomly around the map. They will be stationary in the beginning, but we will still
need a dummy AI class to replace PlayerInput
:
06-physics/entities/components/ai_input.rb
1
class
AiInput
<
Component
2
def
control
(
obj
)
3
self
.
object
=
obj
4
end
5
end
A quick and dirty way to spawn some tanks would be when initializing PlayState
:
class
PlayState
<
GameState
# ...
def
initialize
@map
=
Map
.
new
@camera
=
Camera
.
new
@object_pool
=
ObjectPool
.
new
(
@map
)
@tank
=
Tank
.
new
(
@object_pool
,
PlayerInput
.
new
(
@camera
))
@camera
.
target
=
@tank
# ...
50
.
times
do
Tank
.
new
(
@object_pool
,
AiInput
.
new
)
end
end
# ...
end
And unless we want all stationary tanks face same direction, we will randomize it:
class
Tank
<
GameObject
# ...
def
initialize
(
object_pool
,
input
)
# ...
@direction
=
rand
(
0
.
.
7
)
*
45
@gun_angle
=
rand
(
0
.
.
360
)
end
# ...
end
Fire up the game, and wander around frozen tanks. You can pass through them as if they were ghosts, but we will fix that in a moment.
Adding Bounding Boxes And Detecting Collisions
We want our collision detection to be pixel perfect, that means we need to have a bounding box and check colisions against it. Get ready for some math!
First, we need to find a correct way to construct a bounding box. Tank has it’s body image, so
let’s see how it’s boundaries look like. We will add some code to TankGraphics
component to see
it:
class
TankGraphics
<
Component
def
draw
(
viewport
)
# ...
draw_bounding_box
end
def
draw_bounding_box
$window
.
rotate
(
object
.
direction
,
x
,
y
)
do
w
=
@body
.
width
h
=
@body
.
height
$window
.
draw_quad
(
x
-
w
/
2
,
y
-
h
/
2
,
Gosu
::
Color
::
RED
,
x
+
w
/
2
,
y
-
h
/
2
,
Gosu
::
Color
::
RED
,
x
+
w
/
2
,
y
+
h
/
2
,
Gosu
::
Color
::
RED
,
x
-
w
/
2
,
y
+
h
/
2
,
Gosu
::
Color
::
RED
,
100
)
end
end
# ...
end
Result is pretty good, we have tank shaped box, so we will be using body image dimensions to determine our bounding box corners:
There is one problem here though.
Gosu::Window#rotate
does
the rotation math for us, and we need to perform these calculations on our own. We have four points
that we want to rotate around a center point. It’s not very difficult to find how to do this. Here
is a Ruby method for you:
module
Utils
# ...
def
self
.
rotate
(
angle
,
around_x
,
around_y
,
*
points
)
result
=
[]
points
.
each_slice
(
2
)
do
|
x
,
y
|
r_x
=
Math
.
cos
(
angle
)
*
(
x
-
around_x
)
-
Math
.
sin
(
angle
)
*
(
y
-
around_y
)
+
around_x
r_y
=
Math
.
sin
(
angle
)
*
(
x
-
around_x
)
+
Math
.
cos
(
angle
)
*
(
y
-
around_y
)
+
around_y
result
<<
r_x
result
<<
r_y
end
result
end
# ...
end
We can now calculate edges of our bounding box, but we need one more function which tells if point is inside a polygon. This problem has been solved million times before, so just poke the internet for it and drink from the information firehose until you understand how to do this.
If you wasn’t familiar with the term yet, by now you should discover what vertex is. In geometry, a vertex (plural vertices) is a special kind of point that describes the corners or intersections of geometric shapes.
Here’s what I ended up writing:
module
Utils
# ...
# http://www.ecse.rpi.edu/Homepages/wrf/Research/Short_Notes/pnpoly.html
def
self
.
point_in_poly
(
testx
,
testy
,
*
poly
)
nvert
=
poly
.
size
/
2
# Number of vertices in poly
vertx
=
[]
verty
=
[]
poly
.
each_slice
(
2
)
do
|
x
,
y
|
vertx
<<
x
verty
<<
y
end
inside
=
false
j
=
nvert
-
1
(
0
.
.nvert
-
1
)
.
each
do
|
i
|
if
(((
verty
[
i
]
>
testy
)
!=
(
verty
[
j
]
>
testy
))
&&
(
testx
<
(
vertx
[
j
]
-
vertx
[
i
]
)
*
(
testy
-
verty
[
i
]
)
/
(
verty
[
j
]
-
verty
[
i
]
)
+
vertx
[
i
]
))
inside
=
!
inside
end
j
=
i
end
inside
end
# ...
It is Jordan curve theorem reimplemented in Ruby. Looks ugly, but it actually works, and is pretty fast too.
Also, this works on more sophisticated polygons, and our tank is shaped more like an H rather than a rectangle, so we could define a pixel perfect polygon. Some pen and paper will help.
class
TankPhysics
<
Component
#...
# Tank box looks like H. Vertices:
# 1 2 5 6
# 3 4
#
# 10 9
# 12 11 8 7
def
box
w
=
box_width
/
2
-
1
h
=
box_height
/
2
-
1
tw
=
8
# track width
fd
=
8
# front depth
rd
=
6
# rear depth
Utils
.
rotate
(
object
.
direction
,
x
,
y
,
x
+
w
,
y
+
h
,
#1
x
+
w
-
tw
,
y
+
h
,
#2
x
+
w
-
tw
,
y
+
h
-
fd
,
#3
x
-
w
+
tw
,
y
+
h
-
fd
,
#4
x
-
w
+
tw
,
y
+
h
,
#5
x
-
w
,
y
+
h
,
#6
x
-
w
,
y
-
h
,
#7
x
-
w
+
tw
,
y
-
h
,
#8
x
-
w
+
tw
,
y
-
h
+
rd
,
#9
x
+
w
-
tw
,
y
-
h
+
rd
,
#10
x
+
w
-
tw
,
y
-
h
,
#11
x
+
w
,
y
-
h
,
#12
)
end
# ...
end
To visually see it, we will improve our draw_bounding_box
method:
class
TankGraphics
<
Component
# ...
DEBUG_COLORS
=
[
Gosu
::
Color
::
RED
,
Gosu
::
Color
::
BLUE
,
Gosu
::
Color
::
YELLOW
,
Gosu
::
Color
::
WHITE
]
# ...
def
draw_bounding_box
i
=
0
object
.
box
.
each_slice
(
2
)
do
|
x
,
y
|
color
=
DEBUG_COLORS
[
i
]
$window
.
draw_triangle
(
x
-
3
,
y
-
3
,
color
,
x
,
y
,
color
,
x
+
3
,
y
-
3
,
color
,
100
)
i
=
(
i
+
1
)
%
4
end
end
# ...
Now we can visually test bounding box edges and see that they actually are where they belong.
Time to pimp our TankPhysics
to detect those collisions. While our algorithm is pretty fast, it
doesn’t make sense to check collisions for objects that are pretty far apart. This is why we need
our ObjectPool
to know how to query objects in close proximity.
class
ObjectPool
# ...
def
nearby
(
object
,
max_distance
)
@objects
.
select
do
|
obj
|
distance
=
Utils
.
distance_between
(
obj
.
x
,
obj
.
y
,
object
.
x
,
object
.
y
)
obj
!=
object
&&
distance
<
max_distance
end
end
end
Back to TankPhysics
:
class
TankPhysics
<
Component
# ...
def
can_move_to?
(
x
,
y
)
old_x
,
old_y
=
object
.
x
,
object
.
y
object
.
x
=
x
object
.
y
=
y
return
false
unless
@map
.
can_move_to?
(
x
,
y
)
@object_pool
.
nearby
(
object
,
100
)
.
each
do
|
obj
|
if
collides_with_poly?
(
obj
.
box
)
# Allow to get unstuck
old_distance
=
Utils
.
distance_between
(
obj
.
x
,
obj
.
y
,
old_x
,
old_y
)
new_distance
=
Utils
.
distance_between
(
obj
.
x
,
obj
.
y
,
x
,
y
)
return
false
if
new_distance
<
old_distance
end
end
true
ensure
object
.
x
=
old_x
object
.
y
=
old_y
end
# ...
private
def
collides_with_poly?
(
poly
)
if
poly
poly
.
each_slice
(
2
)
do
|
x
,
y
|
return
true
if
Utils
.
point_in_poly
(
x
,
y
,
*
box
)
end
box
.
each_slice
(
2
)
do
|
x
,
y
|
return
true
if
Utils
.
point_in_poly
(
x
,
y
,
*
poly
)
end
end
false
end
# ...
end
It’s probably not the most elegant solution you could come up with, but can_move_to?
temporarily
changes Tank
location to make a collision test, and then reverts old coordinates just before
returning the result. Now our tanks stop with banging sound when they hit each other.
Catching Bullets
Right now bullets fly right through our tanks, and we want them to collide. It’s a pretty simple
change, which mostly affects BulletPhysics
class:
# 06-physics/entities/components/bullet_physics.rb
class
BulletPhysics
<
Component
# ...
def
update
# ...
check_hit
object
.
explode
if
arrived?
end
# ...
private
def
check_hit
@object_pool
.
nearby
(
object
,
50
)
.
each
do
|
obj
|
next
if
obj
==
object
.
source
# Don't hit source tank
if
Utils
.
point_in_poly
(
x
,
y
,
*
obj
.
box
)
object
.
target_x
=
x
object
.
target_y
=
y
return
end
end
end
# ...
end
Now bullets finally hit, but don’t do any damage yet. We will come back to that soon.
Implementing Turn Speed Penalties
Tanks cannot make turns and go into reverse at full speed while keeping it’s inertia, right? It is
easy to implement. Since it’s related to physics, we will delegate changing Tank
’s @direction
to our TankPhysics
class:
# 06-physics/entities/components/player_input.rb
class
PlayerInput
<
Component
# ...
def
update
# ...
motion_buttons
=
[
Gosu
::
KbW
,
Gosu
::
KbS
,
Gosu
::
KbA
,
Gosu
::
KbD
]
if
any_button_down?
(
*
motion_buttons
)
object
.
throttle_down
=
true
object
.
physics
.
change_direction
(
change_angle
(
object
.
direction
,
*
motion_buttons
))
else
object
.
throttle_down
=
false
end
# ...
end
# ...
end
# 06-physics/entities/components/tank_physics.rb
class
TankPhysics
<
Component
# ...
def
change_direction
(
new_direction
)
change
=
(
new_direction
-
object
.
direction
+
360
)
%
360
change
=
360
-
change
if
change
>
180
if
change
>
90
@speed
=
0
elsif
change
>
45
@speed
*=
0
.
33
elsif
change
>
0
@speed
*=
0
.
66
end
object
.
direction
=
new_direction
end
# ...
end
Implementing Terrain Speed Penalties
Now, let’s see how can we make terrain influence our movement. It sounds reasonable for
TankPhysics
to consult with Map
about speed penalty of current tile:
# 06-physics/entities/map.rb
class
Map
# ...
def
movement_penalty
(
x
,
y
)
tile
=
tile_at
(
x
,
y
)
case
tile
when
@sand
0
.
33
else
0
end
end
# ...
end
# 06-physics/entities/components/tank_physics.rb
class
TankPhysics
<
Component
# ...
def
update
# ...
speed
=
apply_movement_penalty
(
@speed
)
shift
=
Utils
.
adjust_speed
(
speed
)
# ...
end
# ...
private
def
apply_movement_penalty
(
speed
)
speed
*
(
1
.
0
-
@map
.
movement_penalty
(
x
,
y
))
end
# ...
end
This makes all tanks move 33% slower on sand.
Implementing Health And Damage
I know you have been waiting for this. We will be implementing health system and most importantly, damage. Soo we will be ready to blow things up.
To implement this, we need to:
- Add
TankHealth
component. Start with 100 health. - Render tank health next to tank itself.
- Inflict damage to tank when it is in explosion zone
- Render different sprite for dead tank.
- Cut off player input when tank is dead.
Adding Health Component
If we didn’t have Component
system in place, it would be way more difficult. Now we just kick in
a new class:
07-damage/entities/components/tank_health.rb
1
class
TankHealth
<
Component
2
attr_accessor
:health
3
4
def
initialize
(
object
,
object_pool
)
5
super
(
object
)
6
@object_pool
=
object_pool
7
@health
=
100
8
@health_updated
=
true
9
@last_damage
=
Gosu
.
milliseconds
10
end
11
12
def
update
13
update_image
14
end
15
16
def
update_image
17
if
@health_updated
18
if
dead?
19
text
=
'✝'
20
font_size
=
25
21
else
22
text
=
@health
.
to_s
23
font_size
=
18
24
end
25
@image
=
Gosu
::
Image
.
from_text
(
26
$window
,
text
,
27
Gosu
.
default_font_name
,
font_size
)
28
@health_updated
=
false
29
end
30
end
31
32
def
dead?
33
@health
<
1
34
end
35
36
def
inflict_damage
(
amount
)
37
if
@health
>
0
38
@health_updated
=
true
39
@health
=
[
@health
-
amount
.
to_i
,
0
].
max
40
if
@health
<
1
41
Explosion
.
new
(
@object_pool
,
x
,
y
)
42
end
43
end
44
end
45
46
def
draw
(
viewport
)
47
@image
.
draw
(
48
x
-
@image
.
width
/
2
,
49
y
-
object
.
graphics
.
height
/
2
-
50
@image
.
height
,
100
)
51
end
52
end
It hooks itself into the game right away, after we initialize it in Tank
class:
class
Tank
<
GameObject
attr_accessor
:health
# ...
def
initialize
(
object_pool
,
input
)
# ...
@health
=
TankHealth
.
new
(
self
,
object_pool
)
# ..
end
# ..
end
Inflicting Damage With Bullets
There are two ways to inflict damage - directly and indirectly. When bullet hits enemy tank
(collides with tank bounding box), we should inflict direct damage. It can be done in
BulletPhysics#check_hit
method that we already had:
class
BulletPhysics
<
Component
# ...
def
check_hit
@object_pool
.
nearby
(
object
,
50
)
.
each
do
|
obj
|
next
if
obj
==
object
.
source
# Don't hit source tank
if
Utils
.
point_in_poly
(
x
,
y
,
*
obj
.
box
)
# Direct hit - extra damage
obj
.
health
.
inflict_damage
(
20
)
object
.
target_x
=
x
object
.
target_y
=
y
return
end
end
end
# ...
end
Finally, Explosion
itself should inflict additional damage to anything that are nearby. The
effect will be diminishing and it will be determined by object distance.
class
Explosion
<
GameObject
# ...
def
initialize
(
object_pool
,
x
,
y
)
# ...
inflict_damage
end
private
def
inflict_damage
object_pool
.
nearby
(
self
,
100
)
.
each
do
|
obj
|
if
obj
.
class
==
Tank
obj
.
health
.
inflict_damage
(
Math
.
sqrt
(
3
*
100
-
Utils
.
distance_between
(
obj
.
x
,
obj
.
y
,
x
,
y
)))
end
end
end
end
This is it, we are ready to deal damage. But we want to see if we actually killed somebody, so
TankGraphics
should be aware of health and should draw different set of sprites when tank is
dead. Here is what we need to change in our current TankGraphics to achieve the result:
class
TankGraphics
<
Component
# ...
def
initialize
(
game_object
)
super
(
game_object
)
@body_normal
=
units
.
frame
(
'tank1_body.png'
)
@shadow_normal
=
units
.
frame
(
'tank1_body_shadow.png'
)
@gun_normal
=
units
.
frame
(
'tank1_dualgun.png'
)
@body_dead
=
units
.
frame
(
'tank1_body_destroyed.png'
)
@shadow_dead
=
units
.
frame
(
'tank1_body_destroyed_shadow.png'
)
@gun_dead
=
nil
end
def
update
if
object
.
health
.
dead?
@body
=
@body_dead
@gun
=
@gun_dead
@shadow
=
@shadow_dead
else
@body
=
@body_normal
@gun
=
@gun_normal
@shadow
=
@shadow_normal
end
end
def
draw
(
viewport
)
@shadow
.
draw_rot
(
x
-
1
,
y
-
1
,
0
,
object
.
direction
)
@body
.
draw_rot
(
x
,
y
,
1
,
object
.
direction
)
@gun
.
draw_rot
(
x
,
y
,
2
,
object
.
gun_angle
)
if
@gun
end
# ...
end
Now we can blow them up and enjoy the view:
But what if we blow ourselves up by shooting nearby? We would still be able to move around. To fix this, we will simply cut out player input when we are dead:
class
PlayerInput
<
Component
# ...
def
update
return
if
object
.
health
.
dead?
# ...
end
# ...
end
And to prevent tank from throttling forever if the pedal was down before it got killed:
class
TankPhysics
<
Component
# ...
def
update
if
object
.
throttle_down
&&
!
object
.
health
.
dead?
accelerate
else
decelerate
end
# ...
end
# ...
end
That’s it. All we need right now is some resistance from those brain dead enemies. We will spark some life into them in next chapter.
Creating Artificial Intelligence
Artificial Intelligence is a subject so vast that we will barely scratch the surface. AI in Video Games is usually heavily simplified and therefore easier to implement.
There is this wonderful series of articles called
Designing Artificial Intelligence for Games
that I highly recommend reading to get a feeling how game AI should be done. We will be
continuing our work on top of what we already have, example code for this chapter will be in 08-ai
.
Designing AI Using Finite State Machine
Non player tanks in our game will be lone rangers, hunting everything that moves while trying to survive. We will use Finite State Machine to implement tank behavior.
First, we need to think “what would a tank do?” How about this scenario:
- Tank wanders around, minding it’s own business.
- Tank encounters another tank. It then starts doing evasive moves and tries hitting the enemy.
- Enemy took some damage and started driving away. Tank starts chasing the enemy trying to finish it.
- Another tank appears and fires a couple of accurate shots, dealing serious damage. Our tank starts running away, because if it kept receiving damage at such rate, it would die very soon.
- Tank keeps fleeing and looking for safety until it gets cornered or the opponent looks damaged too. Then tank goes into it’s final battle.
We can now draw a Finite State Machine using this scenario:
If you are on a path to become a game developer, FSM should not stand for Flying Spaghetti Monster for you anymore.
Implementing AI Vision
To make opponents realistic, we have to give them senses. Let’s create a class for that:
08-ai/entities/components/ai/vision.rb
1
class
AiVision
2
CACHE_TIMEOUT
=
500
3
attr_reader
:in_sight
4
5
def
initialize
(
viewer
,
object_pool
,
distance
)
6
@viewer
=
viewer
7
@object_pool
=
object_pool
8
@distance
=
distance
9
end
10
11
def
update
12
@in_sight
=
@object_pool
.
nearby
(
@viewer
,
@distance
)
13
end
14
15
def
closest_tank
16
now
=
Gosu
.
milliseconds
17
@closest_tank
=
nil
18
if
now
-
(
@cache_updated_at
||=
0
)
>
CACHE_TIMEOUT
19
@closest_tank
=
nil
20
@cache_updated_at
=
now
21
end
22
@closest_tank
||=
find_closest_tank
23
end
24
25
private
26
27
def
find_closest_tank
28
@in_sight
.
select
do
|
o
|
29
o
.
class
==
Tank
&&
!
o
.
health
.
dead?
30
end
.
sort
do
|
a
,
b
|
31
x
,
y
=
@viewer
.
x
,
@viewer
.
y
32
d1
=
Utils
.
distance_between
(
x
,
y
,
a
.
x
,
a
.
y
)
33
d2
=
Utils
.
distance_between
(
x
,
y
,
b
.
x
,
b
.
y
)
34
d1
<=>
d2
35
end
.
first
36
end
37
end
It uses ObjectPool
to put nearby objects in sight, and gets a short term focus on one closest
tank. Closest tank is cached for 500 milliseconds for two reasons:
- Performance. Uncached version would do
Array#select
andArray#sort
60 times per second, now it will do 2 times. - Focus. When you choose a target, you should keep it a little longer. This should also avoid “jitters”, when tank would shake between two nearby targets that are within same distance.
Controlling Tank Gun
After we made AiVision
, we can now use it to automatically aim and shoot at closest tank. It
should work like this:
- Every instance of the gun has it’s own unique combination of speed, accuracy and aggressiveness.
- Gun will automatically target closest tank in sight.
- If no other tank is in sight, gun will target in same direction as tank’s body.
- If other tank is aimed at and within shooting distance, gun will make a decision once in a while whether it should shoot or not, based on aggressiveness level. Aggressive tanks will be trigger happy all the time, while less aggressive ones will make small random pauses between shots.
- Gun will have a “desired” angle that it will be automatically adjusting to, according to it’s speed.
Here is the implementation:
08-ai/entities/components/ai/gun.rb
1
class
AiGun
2
DECISION_DELAY
=
1000
3
attr_reader
:target
,
:desired_gun_angle
4
5
def
initialize
(
object
,
vision
)
6
@object
=
object
7
@vision
=
vision
8
@desired_gun_angle
=
rand
(
0
.
.
360
)
9
@retarget_speed
=
rand
(
1
.
.
5
)
10
@accuracy
=
rand
(
0
.
.
10
)
11
@aggressiveness
=
rand
(
1
.
.
5
)
12
end
13
14
def
adjust_angle
15
adjust_desired_angle
16
adjust_gun_angle
17
end
18
19
def
update
20
if
@vision
.
in_sight
.
any?
21
if
@vision
.
closest_tank
!=
@target
22
change_target
(
@vision
.
closest_tank
)
23
end
24
else
25
@target
=
nil
26
end
27
28
if
@target
29
if
(
0
.
.
10
-
rand
(
0
.
.
@accuracy
))
.
include?
(
30
(
@desired_gun_angle
-
@object
.
gun_angle
)
.
abs
.
round
)
31
distance
=
distance_to_target
32
if
distance
-
50
<=
BulletPhysics
::
MAX_DIST
33
target_x
,
target_y
=
Utils
.
point_at_distance
(
34
@object
.
x
,
@object
.
y
,
@object
.
gun_angle
,
35
distance
+
10
-
rand
(
0
.
.
@accuracy
))
36
if
can_make_new_decision?
&&
@object
.
can_shoot?
&&
37
should_shoot?
38
@object
.
shoot
(
target_x
,
target_y
)
39
end
40
end
41
end
42
end
43
end
44
45
def
distance_to_target
46
Utils
.
distance_between
(
47
@object
.
x
,
@object
.
y
,
@target
.
x
,
@target
.
y
)
48
end
49
50
51
def
should_shoot?
52
rand
*
@aggressiveness
>
0
.
5
53
end
54
55
def
can_make_new_decision?
56
now
=
Gosu
.
milliseconds
57
if
now
-
(
@last_decision
||=
0
)
>
DECISION_DELAY
58
@last_decision
=
now
59
true
60
end
61
end
62
63
def
adjust_desired_angle
64
@desired_gun_angle
=
if
@target
65
Utils
.
angle_between
(
66
@object
.
x
,
@object
.
y
,
@target
.
x
,
@target
.
y
)
67
else
68
@object
.
direction
69
end
70
end
71
72
def
change_target
(
new_target
)
73
@target
=
new_target
74
adjust_desired_angle
75
end
76
77
def
adjust_gun_angle
78
actual
=
@object
.
gun_angle
79
desired
=
@desired_gun_angle
80
if
actual
>
desired
81
if
actual
-
desired
>
180
# 0 -> 360 fix
82
@object
.
gun_angle
=
(
actual
+
@retarget_speed
)
%
360
83
if
@object
.
gun_angle
<
desired
84
@object
.
gun_angle
=
desired
# damp
85
end
86
else
87
@object
.
gun_angle
=
[
actual
-
@retarget_speed
,
desired
].
max
88
end
89
elsif
actual
<
desired
90
if
desired
-
actual
>
180
# 360 -> 0 fix
91
@object
.
gun_angle
=
(
360
+
actual
-
@retarget_speed
)
%
360
92
if
@object
.
gun_angle
>
desired
93
@object
.
gun_angle
=
desired
# damp
94
end
95
else
96
@object
.
gun_angle
=
[
actual
+
@retarget_speed
,
desired
].
min
97
end
98
end
99
end
100
end
There is some math involved, but it is pretty straightforward. We need to find out an angle between two points, to know where our gun should point, and the other thing we need is coordinates of point which is in some distance away from source at given angle. Here are those functions:
module
Utils
# ...
def
self
.
angle_between
(
x
,
y
,
target_x
,
target_y
)
dx
=
target_x
-
x
dy
=
target_y
-
y
(
180
-
Math
.
atan2
(
dx
,
dy
)
*
180
/
Math
::
PI
)
+
360
%
360
end
def
self
.
point_at_distance
(
source_x
,
source_y
,
angle
,
distance
)
angle
=
(
90
-
angle
)
*
Math
::
PI
/
180
x
=
source_x
+
Math
.
cos
(
angle
)
*
distance
y
=
source_y
-
Math
.
sin
(
angle
)
*
distance
[
x
,
y
]
end
# ...
end
Implementing AI Input
At this point our tanks can already defend themselves, even through motion is not yet implemented.
Let’s wire everything we have in AiInput
class that we had prepared earlier. We
will need a blank TankMotionFSM
class with 3 argument initializer and empty update
,
on_collision(with)
and on_damage(amount)
methods for it to work:
08-ai/entities/components/ai_input.rb
1
class
AiInput
<
Component
2
UPDATE_RATE
=
200
# ms
3
4
def
initialize
(
object_pool
)
5
@object_pool
=
object_pool
6
super
(
nil
)
7
@last_update
=
Gosu
.
milliseconds
8
end
9
10
def
control
(
obj
)
11
self
.
object
=
obj
12
@vision
=
AiVision
.
new
(
obj
,
@object_pool
,
13
rand
(
700
.
.
1200
))
14
@gun
=
AiGun
.
new
(
obj
,
@vision
)
15
@motion
=
TankMotionFSM
.
new
(
obj
,
@vision
,
@gun
)
16
end
17
18
def
on_collision
(
with
)
19
@motion
.
on_collision
(
with
)
20
end
21
22
def
on_damage
(
amount
)
23
@motion
.
on_damage
(
amount
)
24
end
25
26
def
update
27
return
if
object
.
health
.
dead?
28
@gun
.
adjust_angle
29
now
=
Gosu
.
milliseconds
30
return
if
now
-
@last_update
<
UPDATE_RATE
31
@last_update
=
now
32
@vision
.
update
33
@gun
.
update
34
@motion
.
update
35
end
36
end
It adjust gun angle all the time, but does updates at UPDATE_RATE
to save CPU power. AI is
usually one of the most CPU intensive things in games, so it’s a common practice to execute it less
often. Refreshing enemy brains 5 per second is enough to make them deadly.
Make sure you spawn some AI controlled tanks in PlayState
and try killing them now. I bet they
will eventually get you even while standing still. You can also make tanks spawn below mouse cursor
when you press T
key:
class
PlayState
<
GameState
# ...
def
initialize
# ...
10
.
times
do
|
i
|
Tank
.
new
(
@object_pool
,
AiInput
.
new
(
@object_pool
))
end
end
# ...
def
button_down
(
id
)
# ...
if
id
==
Gosu
::
KbT
t
=
Tank
.
new
(
@object_pool
,
AiInput
.
new
(
@object_pool
))
t
.
x
,
t
.
y
=
@camera
.
mouse_coords
end
# ...
end
# ...
end
Implementing Tank Motion States
This is the place where we will need Finite State Machine to get things right. We will design it like this:
-
TankMotionFSM
will decide which motion state tank should be in, considering various parameters, e.g. existence of target or lack thereof, health, etc. - There will be
TankMotionState
base class that will offer common methods likedrive
,wait
andon_collision
. - Concrete motion classes will implement
update
,change_direction
and other methods, that will fiddle withTank#throttle_down
andTank#direction
to make it move and turn.
We will begin with TankMotionState
:
08-ai/entities/components/ai/tank_motion_state.rb
1
class
TankMotionState
2
def
initialize
(
object
,
vision
)
3
@object
=
object
4
@vision
=
vision
5
end
6
7
def
enter
8
# Override if necessary
9
end
10
11
def
change_direction
12
# Override
13
end
14
15
def
wait_time
16
# Override and return a number
17
end
18
19
def
drive_time
20
# Override and return a number
21
end
22
23
def
turn_time
24
# Override and return a number
25
end
26
27
def
update
28
# Override
29
end
30
31
def
wait
32
@sub_state
=
:waiting
33
@started_waiting
=
Gosu
.
milliseconds
34
@will_wait_for
=
wait_time
35
@object
.
throttle_down
=
false
36
end
37
38
def
drive
39
@sub_state
=
:driving
40
@started_driving
=
Gosu
.
milliseconds
41
@will_drive_for
=
drive_time
42
@object
.
throttle_down
=
true
43
end
44
45
def
should_change_direction?
46
return
true
unless
@changed_direction_at
47
Gosu
.
milliseconds
-
@changed_direction_at
>
48
@will_keep_direction_for
49
end
50
51
def
substate_expired?
52
now
=
Gosu
.
milliseconds
53
case
@sub_state
54
when
:waiting
55
true
if
now
-
@started_waiting
>
@will_wait_for
56
when
:driving
57
true
if
now
-
@started_driving
>
@will_drive_for
58
else
59
true
60
end
61
end
62
63
def
on_collision
(
with
)
64
change
=
case
rand
(
0
.
.
100
)
65
when
0
.
.
30
66
-
90
67
when
30
.
.
60
68
90
69
when
60
.
.
70
70
135
71
when
80
.
.
90
72
-
135
73
else
74
180
75
end
76
@object
.
physics
.
change_direction
(
77
@object
.
direction
+
change
)
78
end
79
end
Nothing extraordinary here, and we need a concrete implementation to get a feeling how it would
work, therefore let’s examine TankRoamingState
. It will be the default state which tank would be
in if there were no enemies around.
Tank Roaming State
08-ai/entities/components/ai/tank_roaming_state.rb
1
class
TankRoamingState
<
TankMotionState
2
def
initialize
(
object
,
vision
)
3
super
4
@object
=
object
5
@vision
=
vision
6
end
7
8
def
update
9
change_direction
if
should_change_direction?
10
if
substate_expired?
11
rand
>
0
.
3
?
drive
:
wait
12
end
13
end
14
15
def
change_direction
16
change
=
case
rand
(
0
.
.
100
)
17
when
0
.
.
30
18
-
45
19
when
30
.
.
60
20
45
21
when
60
.
.
70
22
90
23
when
80
.
.
90
24
-
90
25
else
26
0
27
end
28
if
change
!=
0
29
@object
.
physics
.
change_direction
(
30
@object
.
direction
+
change
)
31
end
32
@changed_direction_at
=
Gosu
.
milliseconds
33
@will_keep_direction_for
=
turn_time
34
end
35
36
def
wait_time
37
rand
(
500
.
.
2000
)
38
end
39
40
def
drive_time
41
rand
(
1000
.
.
5000
)
42
end
43
44
def
turn_time
45
rand
(
2000
.
.
5000
)
46
end
47
end
The logic here:
- Tank will randomly change direction every
turn_time
interval, which is between 2 and 5 seconds. - Tank will choose to drive (80% chance) or to stand still (20% chance).
- If tank chose to drive, it will keep driving for
drive_time
, which is between 1 and 5 seconds. - Same goes with waiting, but
wait_time
(0.5 - 2 seconds) will be used for duration. - Direction changes and driving / waiting are independent.
This will make an impression that our tank is driving around looking for enemies.
Tank Fighting State
When tank finally sees an opponent, it will start fighting. Fighting motion should be more
energetic than roaming, we will need a sharper set of choices in change_direction
among other
things.
08-ai/entities/components/ai/tank_fighting_state.rb
1
class
TankFightingState
<
TankMotionState
2
def
initialize
(
object
,
vision
)
3
super
4
@object
=
object
5
@vision
=
vision
6
end
7
8
def
update
9
change_direction
if
should_change_direction?
10
if
substate_expired?
11
rand
>
0
.
2
?
drive
:
wait
12
end
13
end
14
15
def
change_direction
16
change
=
case
rand
(
0
.
.
100
)
17
when
0
.
.
20
18
-
45
19
when
20
.
.
40
20
45
21
when
40
.
.
60
22
90
23
when
60
.
.
80
24
-
90
25
when
80
.
.
90
26
135
27
when
90
.
.
100
28
-
135
29
end
30
@object
.
physics
.
change_direction
(
31
@object
.
direction
+
change
)
32
@changed_direction_at
=
Gosu
.
milliseconds
33
@will_keep_direction_for
=
turn_time
34
end
35
36
def
wait_time
37
rand
(
300
.
.
1000
)
38
end
39
40
def
drive_time
41
rand
(
2000
.
.
5000
)
42
end
43
44
def
turn_time
45
rand
(
500
.
.
2500
)
46
end
47
end
We will have much less waiting and much more driving and turning.
Tank Chasing State
If opponent is fleeing, we will want to set our direction towards the opponent and hit pedal to the
metal. No waiting here. AiGun#desired_gun_angle
will point directly to our enemy.
08-ai/entities/components/ai/tank_chasing_state.rb
1
class
TankChasingState
<
TankMotionState
2
def
initialize
(
object
,
vision
,
gun
)
3
super
(
object
,
vision
)
4
@object
=
object
5
@vision
=
vision
6
@gun
=
gun
7
end
8
9
def
update
10
change_direction
if
should_change_direction?
11
drive
12
end
13
14
def
change_direction
15
@object
.
physics
.
change_direction
(
16
@gun
.
desired_gun_angle
-
17
@gun
.
desired_gun_angle
%
45
)
18
19
@changed_direction_at
=
Gosu
.
milliseconds
20
@will_keep_direction_for
=
turn_time
21
end
22
23
def
drive_time
24
10000
25
end
26
27
def
turn_time
28
rand
(
300
.
.
600
)
29
end
30
end
Tank Fleeing State
Now, if our health is low, we will do the opposite of chasing. Gun will be pointing and shooting at the
opponent, but we want body to move away, so we won’t get ourselves killed. It is very similar to
TankChasingState
where change_direction
adds extra 180 degrees to the equation, but there is
one more thing. Tank can only flee for a while. Then it gets itself together and goes into final
battle. That’s why we provide can_flee?
method that TankMotionFSM
will consult with before
entering fleeing state.
We have implemented all the states, that means we are moments away from actually playable prototype with tank bots running around and fighting with you and each other.
Wiring Tank Motion States Into Finite State Machine
Implementing TankMotionFSM
after we have all motion states ready is surprisingly easy:
08-ai/entities/components/ai/tank_motion_fsm.rb
1
class
TankMotionFSM
2
STATE_CHANGE_DELAY
=
500
3
4
def
initialize
(
object
,
vision
,
gun
)
5
@object
=
object
6
@vision
=
vision
7
@gun
=
gun
8
@roaming_state
=
TankRoamingState
.
new
(
object
,
vision
)
9
@fighting_state
=
TankFightingState
.
new
(
object
,
vision
)
10
@fleeing_state
=
TankFleeingState
.
new
(
object
,
vision
,
gun
)
11
@chasing_state
=
TankChasingState
.
new
(
object
,
vision
,
gun
)
12
set_state
(
@roaming_state
)
13
end
14
15
def
on_collision
(
with
)
16
@current_state
.
on_collision
(
with
)
17
end
18
19
def
on_damage
(
amount
)
20
if
@current_state
==
@roaming_state
21
set_state
(
@fighting_state
)
22
end
23
end
24
25
def
update
26
choose_state
27
@current_state
.
update
28
end
29
30
def
set_state
(
state
)
31
return
unless
state
32
return
if
state
==
@current_state
33
@last_state_change
=
Gosu
.
milliseconds
34
@current_state
=
state
35
state
.
enter
36
end
37
38
def
choose_state
39
return
unless
Gosu
.
milliseconds
-
40
(
@last_state_change
)
>
STATE_CHANGE_DELAY
41
if
@gun
.
target
42
if
@object
.
health
.
health
>
40
43
if
@gun
.
distance_to_target
>
BulletPhysics
::
MAX_DIST
44
new_state
=
@chasing_state
45
else
46
new_state
=
@fighting_state
47
end
48
else
49
if
@fleeing_state
.
can_flee?
50
new_state
=
@fleeing_state
51
else
52
new_state
=
@fighting_state
53
end
54
end
55
else
56
new_state
=
@roaming_state
57
end
58
set_state
(
new_state
)
59
end
60
end
All the logic is in choose_state
method, which is pretty ugly and procedural, but it does the
job. The code should be easy to understand, so instead of describing it, here is a picture worth
thousand words:
You may notice a new crosshair, which replaced the old one that was never visible:
class
Camera
# ...
def
draw_crosshair
factor
=
0
.
5
x
=
$window
.
mouse_x
y
=
$window
.
mouse_y
c
=
crosshair
c
.
draw
(
x
-
c
.
width
*
factor
/
2
,
y
-
c
.
height
*
factor
/
2
,
1000
,
factor
,
factor
)
end
# ...
private
def
crosshair
@crosshair
||=
Gosu
::
Image
.
new
(
$window
,
Utils
.
media_path
(
'c_dot.png'
),
false
)
end
end
However this new crosshair didn’t help me win, I got my ass kicked badly. Increasing game window size helped, but we obviously need to fine tune many things in this AI, to make it smart and challenging rather than dumb and deadly accurate.
Making The Prototype Playable
Right now we have a somewhat playable, but boring prototype without any scores or winning conditions. You can just run around and shoot other tanks. Nobody would play a game like this, hence we need to to add the missing parts. There is a crazy amount of them. It is time to give it a thorough play through and write down all the ideas and pain points about the prototype.
Here is my list:
- Enemy tanks do not respawn.
- Enemy tanks shoot at my current location, not at where I will be when bullet hits me.
- Enemy tanks don’t avoid collisions.
- Random maps are boring and lack detail, could use more tiles or random environment objects.
- Bullets are hard to see on green surface.
- Hard to tell where enemies are coming from, radar would help.
- Sounds play at full volume even when something happens across the whole map.
- My tank should respawn after it’s dead.
- Motion and firing mechanics seem clumsy.
- Map boundaries are visible when you come to the edge.
- Enemy tank movement patterns need polishing and improvement.
- Both my tank and enemies don’t have any identity. Sometimes hard to distinguish who is who.
- No idea who has most kills. HUD with score and some state that displays score details would help.
- Would be great to have random powerups like health, extra damage.
- Explosions don’t leave a trace.
- Tanks could leave trails.
- Dead tanks keep piling up and cluttering the map.
- Camera should be scouting ahead of you when you move, not dragging behind.
- Bullets seem to accelerate.
This will keep us busy for a while, but in the end we will probably have something that will hopefully be able to entertain people for more than 3 minutes.
Some items on this list are easy fixes. After playing around with Pixelmator for 15 minutes, I ended up with a bullet that is visible on both light and dark backgrounds:
Motion and firing mechanics will either have to be tuned setting by setting, or rewritten from scratch. Implementing score system, powerups and improving enemy AI deserve to have chapters of their own. The rest can be taken care of right away.
Drawing Water Beyond Map Boundaries
We don’t want to see darkness when we come to the edge of game world. Luckily, it is a trivial fix.
In Map#draw
we check if tile exists in map before drawing it. When tile does not exist, we can
draw water instead. And we can always fallback to water tile in Map#tile_at
:
class
Map
# ...
def
draw
(
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
]
map_x
=
x
*
TILE_SIZE
map_y
=
y
*
TILE_SIZE
if
row
tile
=
@map
[
x
][
y
]
if
tile
tile
.
draw
(
map_x
,
map_y
,
0
)
else
@water
.
draw
(
map_x
,
map_y
,
0
)
end
else
@water
.
draw
(
map_x
,
map_y
,
0
)
end
end
end
end
# ...
private
# ...
def
tile_at
(
x
,
y
)
t_x
=
((
x
/
TILE_SIZE
)
%
TILE_SIZE
)
.
floor
t_y
=
((
y
/
TILE_SIZE
)
%
TILE_SIZE
)
.
floor
row
=
@map
[
t_x
]
row
?
row
[
t_y
]
:
@water
end
# ...
end
Now the edge looks much better:
Generating Tree Clusters
To make the map more fun to play at, we will generate some trees. Let’s start with Tree
class:
09-polishing/entities/tree.rb
1
class
Tree
<
GameObject
2
attr_reader
:x
,
:y
,
:health
,
:graphics
3
4
def
initialize
(
object_pool
,
x
,
y
,
seed
)
5
super
(
object_pool
)
6
@x
,
@y
=
x
,
y
7
@graphics
=
TreeGraphics
.
new
(
self
,
seed
)
8
@health
=
Health
.
new
(
self
,
object_pool
,
30
,
false
)
9
@angle
=
rand
(
-
15
.
.
15
)
10
end
11
12
def
on_collision
(
object
)
13
@graphics
.
shake
(
object
.
direction
)
14
end
15
16
def
box
17
[
x
,
y
]
18
end
19
end
Nothing fancy here, we want it to shake on collision, and it has graphics and health. seed
will
used to generate clusters of similar trees. Let’s take a look at TreeGraphics
:
09-polishing/entities/components/tree_graphics.rb
1
class
TreeGraphics
<
Component
2
SHAKE_TIME
=
100
3
SHAKE_COOLDOWN
=
200
4
SHAKE_DISTANCE
=
[
2
,
1
,
0
,
-
1
,
-
2
,
-
1
,
0
,
1
,
0
,
-
1
,
0
]
5
def
initialize
(
object
,
seed
)
6
super
(
object
)
7
load_sprite
(
seed
)
8
end
9
10
def
shake
(
direction
)
11
now
=
Gosu
.
milliseconds
12
return
if
@shake_start
&&
13
now
-
@shake_start
<
SHAKE_TIME
+
SHAKE_COOLDOWN
14
@shake_start
=
now
15
@shake_direction
=
direction
16
@shaking
=
true
17
end
18
19
def
adjust_shake
(
x
,
y
,
shaking_for
)
20
elapsed
=
[
shaking_for
,
SHAKE_TIME
].
min
/
SHAKE_TIME
.
to_f
21
frame
=
((
SHAKE_DISTANCE
.
length
-
1
)
*
elapsed
)
.
floor
22
distance
=
SHAKE_DISTANCE
[
frame
]
23
Utils
.
point_at_distance
(
x
,
y
,
@shake_direction
,
distance
)
24
end
25
26
def
draw
(
viewport
)
27
if
@shaking
28
shaking_for
=
Gosu
.
milliseconds
-
@shake_start
29
shaking_x
,
shaking_y
=
adjust_shake
(
30
center_x
,
center_y
,
shaking_for
)
31
@tree
.
draw
(
shaking_x
,
shaking_y
,
5
)
32
if
shaking_for
>=
SHAKE_TIME
33
@shaking
=
false
34
end
35
else
36
@tree
.
draw
(
center_x
,
center_y
,
5
)
37
end
38
Utils
.
mark_corners
(
object
.
box
)
if
$debug
39
end
40
41
def
height
42
@tree
.
height
43
end
44
45
def
width
46
@tree
.
width
47
end
48
49
private
50
51
def
load_sprite
(
seed
)
52
frame_list
=
trees
.
frame_list
53
frame
=
frame_list
[
(
frame_list
.
size
*
seed
)
.
round
]
54
@tree
=
trees
.
frame
(
frame
)
55
end
56
57
def
center_x
58
@center_x
||=
x
-
@tree
.
width
/
2
59
end
60
61
def
center_y
62
@center_y
||=
y
-
@tree
.
height
/
2
63
end
64
65
def
trees
66
@@trees
||=
Gosu
::
TexturePacker
.
load_json
(
$window
,
67
Utils
.
media_path
(
'trees_packed.json'
))
68
end
69
end
Shaking is probably the most interesting part here. When shake
is called, graphics will start
drawing tree shifted in given direction by amount defined in SHAKE_DISTANCE
array. draw
will be
stepping through SHAKE_DISTANCE
depending on SHAKE_TIME
, and it will not be shaken again for
SHAKE_COOLDOWN
period, to avoid infinite shaking while driving into it.
We also need some adjustments to TankPhysics
and Tank
to be able to hit trees. First, we want
to create an empty on_collision(object)
method in GameObject
class, so all game objects will
be able to collide.
Then, TankPhysics
starts calling Tank#on_collision
when collision is detected:
class
Tank
<
GameObject
# ...
def
on_collision
(
object
)
return
unless
object
# Avoid recursion
if
object
.
class
==
Tank
# Inform AI about hit
object
.
input
.
on_collision
(
object
)
else
# Call only on non-tanks to avoid recursion
object
.
on_collision
(
self
)
end
# Bullets should not slow Tanks down
if
object
.
class
!=
Bullet
@sounds
.
collide
if
@physics
.
speed
>
1
end
end
# ...
end
The final ingredient to our Tree
is Health
, which is extracted from TankHealth
to reduce
duplication. TankHealth
now extends it:
09-polishing/entities/components/health.rb
1
class
Health
<
Component
2
attr_accessor
:health
3
4
def
initialize
(
object
,
object_pool
,
health
,
explodes
)
5
super
(
object
)
6
@explodes
=
explodes
7
@object_pool
=
object_pool
8
@initial_health
=
@health
=
health
9
@health_updated
=
true
10
end
11
12
def
restore
13
@health
=
@initial_health
14
@health_updated
=
true
15
end
16
17
def
dead?
18
@health
<
1
19
end
20
21
def
update
22
update_image
23
end
24
25
def
inflict_damage
(
amount
)
26
if
@health
>
0
27
@health_updated
=
true
28
@health
=
[
@health
-
amount
.
to_i
,
0
].
max
29
after_death
if
dead?
30
end
31
end
32
33
def
draw
(
viewport
)
34
return
unless
draw?
35
@image
&&
@image
.
draw
(
36
x
-
@image
.
width
/
2
,
37
y
-
object
.
graphics
.
height
/
2
-
38
@image
.
height
,
100
)
39
end
40
41
protected
42
43
def
draw?
44
$debug
45
end
46
47
def
update_image
48
return
unless
draw?
49
if
@health_updated
50
text
=
@health
.
to_s
51
font_size
=
18
52
@image
=
Gosu
::
Image
.
from_text
(
53
$window
,
text
,
54
Gosu
.
default_font_name
,
font_size
)
55
@health_updated
=
false
56
end
57
end
58
59
def
after_death
60
if
@explodes
61
if
Thread
.
list
.
count
<
8
62
Thread
.
new
do
63
sleep
(
rand
(
0
.
1
.
.
0
.
3
))
64
Explosion
.
new
(
@object_pool
,
x
,
y
)
65
sleep
0
.
3
66
object
.
mark_for_removal
67
end
68
else
69
Explosion
.
new
(
@object_pool
,
x
,
y
)
70
object
.
mark_for_removal
71
end
72
else
73
object
.
mark_for_removal
74
end
75
end
76
end
Yes, you can make tree explode when it’s destroyed. And it causes cool chain reactions blowing up whole tree clusters. But let’s not do that, because we will add something more appropriate for explosions.
Our Tree
is ready to fill the landscape. We will do it in Map
class, which will now need
to know about ObjectPool
, because trees will go there.
class
Map
# ...
def
initialize
(
object_pool
)
load_tiles
@object_pool
=
object_pool
object_pool
.
map
=
self
@map
=
generate_map
generate_trees
end
# ...
def
generate_trees
noises
=
Perlin
::
Noise
.
new
(
2
)
contrast
=
Perlin
::
Curve
.
contrast
(
Perlin
::
Curve
::
CUBIC
,
2
)
trees
=
0
target_trees
=
rand
(
300
.
.
500
)
while
trees
<
target_trees
do
x
=
rand
(
0
.
.
MAP_WIDTH
*
TILE_SIZE
)
y
=
rand
(
0
.
.
MAP_HEIGHT
*
TILE_SIZE
)
n
=
noises
[
x
*
0
.
001
,
y
*
0
.
001
]
n
=
contrast
.
call
(
n
)
if
tile_at
(
x
,
y
)
==
@grass
&&
n
>
0
.
5
Tree
.
new
(
@object_pool
,
x
,
y
,
n
*
2
-
1
)
trees
+=
1
end
end
end
# ...
end
Perlin noise is used in similar fashion as it was when we generated map tiles. We allow creating
trees only if noise level is above 0.5
, and use noise level as seed value - n * 2 - 1
will be a
number between 0 and 1 when n
is in 0.5..1
range. And we only allow creating trees on grass
tiles.
Now our map looks a little better:
Generating Random Objects
Trees are great, but we want more detail. Let’s spice things up with explosive boxes and barrels.
They will be using the same class with single sprite sheet, so while the sprite will be chosen
randomly, behavior will be the same. This new class will be called Box
:
09-polishing/entities/box.rb
1
class
Box
<
GameObject
2
attr_reader
:x
,
:y
,
:health
,
:graphics
,
:angle
3
4
def
initialize
(
object_pool
,
x
,
y
)
5
super
(
object_pool
)
6
@x
,
@y
=
x
,
y
7
@graphics
=
BoxGraphics
.
new
(
self
)
8
@health
=
Health
.
new
(
self
,
object_pool
,
10
,
true
)
9
@angle
=
rand
(
-
15
.
.
15
)
10
end
11
12
def
on_collision
(
object
)
13
return
unless
object
.
physics
.
speed
>
1
.
0
14
@x
,
@y
=
Utils
.
point_at_distance
(
@x
,
@y
,
object
.
direction
,
2
)
15
@box
=
nil
16
end
17
18
def
box
19
return
@box
if
@box
20
w
=
@graphics
.
width
/
2
21
h
=
@graphics
.
height
/
2
22
# Bounding box adjusted to trim shadows
23
@box
=
[
x
-
w
+
4
,
y
-
h
+
8
,
24
x
+
w
,
y
-
h
+
8
,
25
x
+
w
,
y
+
h
,
26
x
-
w
+
4
,
y
+
h
]
27
@box
=
Utils
.
rotate
(
@angle
,
@x
,
@y
,
*
@box
)
28
end
29
end
It will be generated with slight random angle, to preserve realistic shadows but give an impression
of chaotic placement. Tanks will also be able to push boxes a little on collision, but only when
going fast enough. Health
component is the same one that Tree
has, but initialized with less
health and explosive flag is true
, so the box will blow up after one hit and deal extra damage to
the surroundings.
BoxGraphics
is nothing fancy, it just loads random sprite upon initialization:
09-polishing/entities/components/box_graphics.rb
1
class
BoxGraphics
<
Component
2
def
initialize
(
object
)
3
super
(
object
)
4
load_sprite
5
end
6
7
def
draw
(
viewport
)
8
@box
.
draw_rot
(
x
,
y
,
0
,
object
.
angle
)
9
Utils
.
mark_corners
(
object
.
box
)
if
$debug
10
end
11
12
def
height
13
@box
.
height
14
end
15
16
def
width
17
@box
.
width
18
end
19
20
private
21
22
def
load_sprite
23
frame
=
boxes
.
frame_list
.
sample
24
@box
=
boxes
.
frame
(
frame
)
25
end
26
27
def
center_x
28
@center_x
||=
x
-
width
/
2
29
end
30
31
def
center_y
32
@center_y
||=
y
-
height
/
2
33
end
34
35
def
boxes
36
@@boxes
||=
Gosu
::
TexturePacker
.
load_json
(
$window
,
37
Utils
.
media_path
(
'boxes_barrels.json'
))
38
end
39
end
Time to generate boxes in our Map
. It will be similar to trees, but we won’t need Perlin noise,
since there will be way fewer boxes than trees, so we don’t need to form patterns. All we need to
do is to check if we’re not generating box on water.
class
Map
# ...
def
initialize
(
object_pool
)
# ...
generate_boxes
end
# ...
def
generate_boxes
boxes
=
0
target_boxes
=
rand
(
10
.
.
30
)
while
boxes
<
target_boxes
do
x
=
rand
(
0
.
.
MAP_WIDTH
*
TILE_SIZE
)
y
=
rand
(
0
.
.
MAP_HEIGHT
*
TILE_SIZE
)
if
tile_at
(
x
,
y
)
!=
@water
Box
.
new
(
@object_pool
,
x
,
y
)
boxes
+=
1
end
end
end
# ...
end
Now give it a go. Beautiful, isn’t it?
Implementing A Radar
With all the visual noise it is getting increasingly difficult to see enemy tanks. That’s why we
will implement a Radar
to help ourselves.
09-polishing/entities/radar.rb
1
class
Radar
2
UPDATE_FREQUENCY
=
1000
3
WIDTH
=
150
4
HEIGHT
=
100
5
PADDING
=
10
6
# Black with 33% transparency
7
BACKGROUND
=
Gosu
::
Color
.
new
(
255
*
0
.
33
,
0
,
0
,
0
)
8
attr_accessor
:target
9
10
def
initialize
(
object_pool
,
target
)
11
@object_pool
=
object_pool
12
@target
=
target
13
@last_update
=
0
14
end
15
16
def
update
17
if
Gosu
.
milliseconds
-
@last_update
>
UPDATE_FREQUENCY
18
@nearby
=
nil
19
end
20
@nearby
||=
@object_pool
.
nearby
(
@target
,
2000
)
.
select
do
|
o
|
21
o
.
class
==
Tank
&&
!
o
.
health
.
dead?
22
end
23
end
24
25
def
draw
26
x1
,
x2
,
y1
,
y2
=
radar_coords
27
$window
.
draw_quad
(
28
x1
,
y1
,
BACKGROUND
,
29
x2
,
y1
,
BACKGROUND
,
30
x2
,
y2
,
BACKGROUND
,
31
x1
,
y2
,
BACKGROUND
,
32
200
)
33
draw_tank
(
@target
,
Gosu
::
Color
::
GREEN
)
34
@nearby
&&
@nearby
.
each
do
|
t
|
35
draw_tank
(
t
,
Gosu
::
Color
::
RED
)
36
end
37
end
38
39
private
40
41
def
draw_tank
(
tank
,
color
)
42
x1
,
x2
,
y1
,
y2
=
radar_coords
43
tx
=
x1
+
WIDTH
/
2
+
(
tank
.
x
-
@target
.
x
)
/
20
44
ty
=
y1
+
HEIGHT
/
2
+
(
tank
.
y
-
@target
.
y
)
/
20
45
if
(
x1
.
.x2
)
.
include?
(
tx
)
&&
(
y1
.
.y2
)
.
include?
(
ty
)
46
$window
.
draw_quad
(
47
tx
-
2
,
ty
-
2
,
color
,
48
tx
+
2
,
ty
-
2
,
color
,
49
tx
+
2
,
ty
+
2
,
color
,
50
tx
-
2
,
ty
+
2
,
color
,
51
300
)
52
end
53
end
54
55
def
radar_coords
56
x1
=
$window
.
width
-
WIDTH
-
PADDING
57
x2
=
$window
.
width
-
PADDING
58
y1
=
$window
.
height
-
HEIGHT
-
PADDING
59
y2
=
$window
.
height
-
PADDING
60
[
x1
,
x2
,
y1
,
y2
]
61
end
62
end
Radar
, like Camera
, also has a target. It uses ObjectPool
to query nearby objects and filters
out instances of alive Tank
. Then it draws a transparent black background and small dots for each
tank, green for target, red for the rest.
To avoid querying ObjectPool
too often, Radar
updates itself only once every second.
It is initialized, updated and drawn in PlayState
, right after Camera
:
class
PlayState
<
GameState
# ...
def
initialize
# ...
@camera
.
target
=
@tank
@radar
=
Radar
.
new
(
@object_pool
,
@tank
)
# ...
end
# ...
def
update
# ...
@camera
.
update
@radar
.
update
# ...
end
# ...
def
draw
# ...
@camera
.
draw_crosshair
@radar
.
draw
end
# ...
end
Time to enjoy the results.
Dynamic Sound Volume And Panning
We have improved the visuals, but sound is still terrible. Like some superhero, you can hear everything that happens in the map, and it can drive you insane. We will fix that in a moment.
The idea is to make everything that happens further away from camera target sound less loud, until the sound fades away completely. To make player’s experience more immersive, we will also take advantage of stereo speakers - sounds should appear to be coming from the right direction.
Unfortunately,
Gosu::Sample#play_pan
does not work as one would expect it to. If you play the sample with just a little panning, it
completely cuts off the opposite channel, meaning that if you play a sample with pan level of 0.1
(10% to the right), you would expect to hear something in left speaker as well. The actual behavior
is that sound plays through the right speaker pretty loudly, and if you increase pan level to, say,
0.7
, you will hear the sound through right speaker again, but it will be way more silent.
To implement realistic stereo sounds that come through both speakers when panned, we need to play
two samples with opposite pan
level. After some experimenting, I discovered that fiddling with
pan
level makes things sound weird, while playing with volume produces softer, more subtle
effect. This is what I ended up having:
09-polishing/misc/stereo_sample.rb
1
class
StereoSample
2
@@all_instances
=
[]
3
4
def
self
.
register_instances
(
instances
)
5
@@all_instances
<<
instances
6
end
7
8
def
self
.
cleanup
9
@@all_instances
.
each
do
|
instances
|
10
instances
.
each
do
|
key
,
instance
|
11
unless
instance
.
playing?
||
instance
.
paused?
12
instances
.
delete
(
key
)
13
end
14
end
15
end
16
end
17
18
def
initialize
(
window
,
sound_l
,
sound_r
=
sound_l
)
19
@sound_l
=
Gosu
::
Sample
.
new
(
window
,
sound_l
)
20
# Use same sample in mono -> stereo
21
if
sound_l
==
sound_r
22
@sound_r
=
@sound_l
23
else
24
@sound_r
=
Gosu
::
Sample
.
new
(
window
,
sound_r
)
25
end
26
@instances
=
{}
27
self
.
class
.
register_instances
(
@instances
)
28
end
29
30
def
paused?
(
id
=
:default
)
31
i
=
@instances
[
"
#{
id
}
_l"
]
32
i
&&
i
.
paused?
33
end
34
35
def
playing?
(
id
=
:default
)
36
i
=
@instances
[
"
#{
id
}
_l"
]
37
i
&&
i
.
playing?
38
end
39
40
def
stopped?
(
id
=
:default
)
41
@instances
[
"
#{
id
}
_l"
].
nil?
42
end
43
44
def
play
(
id
=
:default
,
pan
=
0
,
45
volume
=
1
,
speed
=
1
,
looping
=
false
)
46
@instances
[
"
#{
id
}
_l"
]
=
@sound_l
.
play_pan
(
47
-
0
.
2
,
0
,
speed
,
looping
)
48
@instances
[
"
#{
id
}
_r"
]
=
@sound_r
.
play_pan
(
49
0
.
2
,
0
,
speed
,
looping
)
50
volume_and_pan
(
id
,
volume
,
pan
)
51
end
52
53
def
pause
(
id
=
:default
)
54
@instances
[
"
#{
id
}
_l"
].
pause
55
@instances
[
"
#{
id
}
_r"
].
pause
56
end
57
58
def
resume
(
id
=
:default
)
59
@instances
[
"
#{
id
}
_l"
].
resume
60
@instances
[
"
#{
id
}
_r"
].
resume
61
end
62
63
def
stop
64
@instances
.
delete
(
"
#{
id
}
_l"
)
.
stop
65
@instances
.
delete
(
"
#{
id
}
_r"
)
.
stop
66
end
67
68
def
volume_and_pan
(
id
,
volume
,
pan
)
69
if
pan
>
0
70
pan_l
=
1
-
pan
*
2
71
pan_r
=
1
72
else
73
pan_l
=
1
74
pan_r
=
1
+
pan
*
2
75
end
76
pan_l
*=
volume
77
pan_r
*=
volume
78
@instances
[
"
#{
id
}
_l"
].
volume
=
[
pan_l
,
0
.
05
].
max
79
@instances
[
"
#{
id
}
_r"
].
volume
=
[
pan_r
,
0
.
05
].
max
80
end
81
end
StereoSample
manages stereo playback of sample instances, and to avoid memory leaks, it has
cleanup
that scans all sample instances and removes samples that have finished playing. For this
removal to work, we need to place a call to StereoSample.cleanup
inside PlayState#update
method.
To determine correct pan and volume, we will create some helper methods in Utils
module:
module
Utils
HEARING_DISTANCE
=
1000
.
0
# ...
def
self
.
volume
(
object
,
camera
)
return
1
if
object
==
camera
.
target
distance
=
Utils
.
distance_between
(
camera
.
target
.
x
,
camera
.
target
.
y
,
object
.
x
,
object
.
y
)
distance
=
[
(
HEARING_DISTANCE
-
distance
),
0
].
max
distance
/
HEARING_DISTANCE
end
def
self
.
pan
(
object
,
camera
)
return
0
if
object
==
camera
.
target
pan
=
object
.
x
-
camera
.
target
.
x
sig
=
pan
>
0
?
1
:
-
1
pan
=
(
pan
%
HEARING_DISTANCE
)
/
HEARING_DISTANCE
if
sig
>
0
pan
else
-
1
+
pan
end
end
def
self
.
volume_and_pan
(
object
,
camera
)
[
volume
(
object
,
camera
),
pan
(
object
,
camera
)
]
end
end
Apparently, having access to Camera
is necessary for calculating sound volume and pan, so we will
add attr_accessor :camera
to ObjectPool
class and assign it in PlayState
constructor. You may
wonder why we didn’t use Camera#target
right away. The answer is that camera can change it’s
target. E.g. when your tank dies, new instance will be generated when you respawn, so if all other
objects would still have the reference to your old tank, guess what you would hear?
Remastered TankSounds
component is probably the most elaborate example of how StereoSample
should be used:
09-polishing/entities/components/tank_sounds.rb
1
class
TankSounds
<
Component
2
def
initialize
(
object
,
object_pool
)
3
super
(
object
)
4
@object_pool
=
object_pool
5
end
6
7
def
update
8
id
=
object
.
object_id
9
if
object
.
physics
.
moving?
10
move_volume
=
Utils
.
volume
(
11
object
,
@object_pool
.
camera
)
12
pan
=
Utils
.
pan
(
object
,
@object_pool
.
camera
)
13
if
driving_sound
.
paused?
(
id
)
14
driving_sound
.
resume
(
id
)
15
elsif
driving_sound
.
stopped?
(
id
)
16
driving_sound
.
play
(
id
,
pan
,
0
.
5
,
1
,
true
)
17
end
18
driving_sound
.
volume_and_pan
(
id
,
move_volume
*
0
.
5
,
pan
)
19
else
20
if
driving_sound
.
playing?
(
id
)
21
driving_sound
.
pause
(
id
)
22
end
23
end
24
end
25
26
def
collide
27
vol
,
pan
=
Utils
.
volume_and_pan
(
28
object
,
@object_pool
.
camera
)
29
crash_sound
.
play
(
self
.
object_id
,
pan
,
vol
,
1
,
false
)
30
end
31
32
private
33
34
def
driving_sound
35
@@driving_sound
||=
StereoSample
.
new
(
36
$window
,
Utils
.
media_path
(
'tank_driving.mp3'
))
37
end
38
39
def
crash_sound
40
@@crash_sound
||=
StereoSample
.
new
(
41
$window
,
Utils
.
media_path
(
'metal_interaction2.wav'
))
42
end
43
end
And this is how static ExplosionSounds
looks like:
09-polishing/entities/components/explosion_sounds.rb
1
class
ExplosionSounds
2
class
<<
self
3
def
play
(
object
,
camera
)
4
volume
,
pan
=
Utils
.
volume_and_pan
(
object
,
camera
)
5
sound
.
play
(
object
.
object_id
,
pan
,
volume
)
6
end
7
8
private
9
10
def
sound
11
@@sound
||=
StereoSample
.
new
(
12
$window
,
Utils
.
media_path
(
'explosion.mp3'
))
13
end
14
end
15
end
After wiring everything so that sound components have access to ObjectPool
, the rest is
straightforward.
Giving Enemies Identity
Wouldn’t it be great if you could tell yourself apart from the enemies. Moreover, enemies could have names, so you would know which one is more aggressive or have, you know, personal issues with someone.
To do that we need to ask the player to input a nickname, and choose some funny names for each enemy AI. Here is a nice list we will grab: http://www.paulandstorm.com/wha/clown-names/
We first compile everything into a text filed called names.txt
, that looks like this:
media/names.txt
Strippy
Boffo
Buffo
Drips
.
.
.
Now we need a class to parse the list and give out random names from it. We also want to limit name length to something that displays nicely.
09-polishing/misc/names.rb
1
class
Names
2
def
initialize
(
file
)
3
@names
=
File
.
read
(
file
)
.
split
(
"
\n
"
)
.
reject
do
|
n
|
4
n
.
size
>
12
5
end
6
end
7
8
def
random
9
name
=
@names
.
sample
10
@names
.
delete
(
name
)
11
name
12
end
13
end
Then we need to place those names somewhere. We could assign them to tanks, but think ahead - if
our player and AI enemies will respawn, we should give names to inputs, because Tank
is
replaceable, driver is not. Well, it is, but let’s not get too deep into it.
For now we just add name parameter to PlayerInput
and AiInput
initializers, save it in @name
instance variable, and then add draw(viewport)
method to make it render below the tank:
# 09-polishing/entities/components/player_input.rb
class
PlayerInput
<
Component
# Dark green
NAME_COLOR
=
Gosu
::
Color
.
argb
(
0xee084408
)
def
initialize
(
name
,
camera
)
super
(
nil
)
@name
=
name
@camera
=
camera
end
# ...
def
draw
(
viewport
)
@name_image
||=
Gosu
::
Image
.
from_text
(
$window
,
@name
,
Gosu
.
default_font_name
,
20
)
@name_image
.
draw
(
x
-
@name_image
.
width
/
2
-
1
,
y
+
object
.
graphics
.
height
/
2
,
100
,
1
,
1
,
Gosu
::
Color
::
WHITE
)
@name_image
.
draw
(
x
-
@name_image
.
width
/
2
,
y
+
object
.
graphics
.
height
/
2
,
100
,
1
,
1
,
NAME_COLOR
)
end
# ...
end
# 09-polishing/entities/components/ai_input.rb
class
AiInput
<
Component
# Dark red
NAME_COLOR
=
Gosu
::
Color
.
argb
(
0xeeb10000
)
def
initialize
(
name
,
object_pool
)
super
(
nil
)
@object_pool
=
object_pool
@name
=
name
@last_update
=
Gosu
.
milliseconds
end
# ...
def
draw
(
viewport
)
@motion
.
draw
(
viewport
)
@gun
.
draw
(
viewport
)
@name_image
||=
Gosu
::
Image
.
from_text
(
$window
,
@name
,
Gosu
.
default_font_name
,
20
)
@name_image
.
draw
(
x
-
@name_image
.
width
/
2
-
1
,
y
+
object
.
graphics
.
height
/
2
,
100
,
1
,
1
,
Gosu
::
Color
::
WHITE
)
@name_image
.
draw
(
x
-
@name_image
.
width
/
2
,
y
+
object
.
graphics
.
height
/
2
,
100
,
1
,
1
,
NAME_COLOR
)
end
# ...
end
We can see that generic Input
class can be easily extracted, but let’s follow the
Rule of three and
not do premature refactoring.
Instead, run the game and enjoy dying from a bunch of mad clowns.
Respawning Tanks And Removing Dead Ones
To implement respawning we could use Map#find_spawn_point
every time we wanted to respawn, but it
may get slow, because it brute forces the map for random spots that are not water. We don’t want
our game to start freezing when tanks are respawning, so we will change how tank spawning works.
Instead of looking for a new respawn point all the time, we will pre-generate several of them for
reuse.
class
Map
# ...
def
spawn_points
(
max
)
@spawn_points
=
(
0
.
.max
)
.
map
do
find_spawn_point
end
@spawn_points_pointer
=
0
end
def
spawn_point
@spawn_points
[
(
@spawn_points_pointer
+=
1
)
%
@spawn_points
.
size
]
end
# ...
end
Here we have spawn_points
method that prepares a number of spawn points and stores them in
@spawn_points
instance variable, and spawn_point
method that cycles through all @spawn_points
and returns them one by one. find_spawn_point
can now become private
.
We will use Map#spawn_points
when initializing PlayState
and pass ObjectPool
to PlayerInput
(AiInput
already has it), so that we will be able to call @object_pool.map.spawn_point
when needed.
class
PlayState
<
GameState
# ...
def
initialize
# ...
@map
=
Map
.
new
(
@object_pool
)
@map
.
spawn_points
(
15
)
@tank
=
Tank
.
new
(
@object_pool
,
PlayerInput
.
new
(
'Player'
,
@camera
,
@object_pool
))
# ...
10
.
times
do
|
i
|
Tank
.
new
(
@object_pool
,
AiInput
.
new
(
@names
.
random
,
@object_pool
))
end
end
# ...
end
When tank dies, we want it to stay dead for 5 seconds and then respawn in one of our predefined
spawn points. We will achieve that by adding respawn
method and calling it in PlayerInput#update
and
AiInput#update
if tank is dead.
# 09-polishing/entities/components/player_input.rb
class
PlayerInput
<
Component
# ...
def
update
return
respawn
if
object
.
health
.
dead?
# ...
end
# ...
private
def
respawn
if
object
.
health
.
should_respawn?
object
.
health
.
restore
object
.
x
,
object
.
y
=
@object_pool
.
map
.
spawn_point
@camera
.
x
,
@camera
.
y
=
x
,
y
PlayerSounds
.
respawn
(
object
,
@camera
)
end
end
# ...
end
# 09-polishing/entities/components/ai_input.rb
class
AiInput
<
Component
# ...
def
update
return
respawn
if
object
.
health
.
dead?
# ...
end
# ...
private
def
respawn
if
object
.
health
.
should_respawn?
object
.
health
.
restore
object
.
x
,
object
.
y
=
@object_pool
.
map
.
spawn_point
PlayerSounds
.
respawn
(
object
,
@object_pool
.
camera
)
end
end
end
We need some changes in TankHealth
class too:
class
TankHealth
<
Health
RESPAWN_DELAY
=
5000
# ...
def
should_respawn?
Gosu
.
milliseconds
-
@death_time
>
RESPAWN_DELAY
end
# ...
def
after_death
@death_time
=
Gosu
.
milliseconds
# ...
end
end
class
Health
<
Component
# ...
def
restore
@health
=
@initial_health
@health_updated
=
true
end
# ...
end
It shouldn’t be hard to put everything together and enjoy the never ending gameplay.
You may have noticed that we also added a sound that will be played when player respawns. A nice “whoosh”.
09-polishing/entities/components/player_sounds.rb
1
class
PlayerSounds
2
class
<<
self
3
def
respawn
(
object
,
camera
)
4
volume
,
pan
=
Utils
.
volume_and_pan
(
object
,
camera
)
5
respawn_sound
.
play
(
object
.
object_id
,
pan
,
volume
*
0
.
5
)
6
end
7
8
private
9
10
def
respawn_sound
11
@@respawn
||=
StereoSample
.
new
(
12
$window
,
Utils
.
media_path
(
'respawn.wav'
))
13
end
14
end
15
end
Displaying Explosion Damage Trails
When something blows up, you expect it to leave a trail, right? In our case explosions disappear as
if nothing has ever happened, and we just can’t leave it like this. Let’s introduce Damage
game
object
that will be responsible for displaying explosion residue on sand and grass:
09-polishing/entities/damage.rb
1
class
Damage
<
GameObject
2
MAX_INSTANCES
=
100
3
attr_accessor
:x
,
:y
4
@@instances
=
[]
5
6
def
initialize
(
object_pool
,
x
,
y
)
7
super
(
object_pool
)
8
DamageGraphics
.
new
(
self
)
9
@x
,
@y
=
x
,
y
10
track
(
self
)
11
end
12
13
def
effect?
14
true
15
end
16
17
private
18
19
def
track
(
instance
)
20
if
@@instances
.
size
<
MAX_INSTANCES
21
@@instances
<<
instance
22
else
23
out
=
@@instances
.
shift
24
out
.
mark_for_removal
25
@@instances
<<
instance
26
end
27
end
28
end
Damage
tracks it’s instances and starts removing old ones when MAX_INSTANCES
are reached.
Without this optimization, the game would get increasingly slower every time somebody shoots.
We have also added a new game object trait - effect?
returns true on Damage
and Explosion
,
false on Tank
, Tree
, Box
and Bullet
. That way we can filter out effects when querying
ObjectPool#nearby
for collisions or enemies.
09-polishing/entities/object_pool.rb
1
class
ObjectPool
2
attr_accessor
:objects
,
:map
,
:camera
3
4
def
initialize
5
@objects
=
[]
6
end
7
8
def
nearby
(
object
,
max_distance
)
9
non_effects
.
select
do
|
obj
|
10
obj
!=
object
&&
11
(
obj
.
x
-
object
.
x
)
.
abs
<
max_distance
&&
12
(
obj
.
y
-
object
.
y
)
.
abs
<
max_distance
&&
13
Utils
.
distance_between
(
14
obj
.
x
,
obj
.
y
,
object
.
x
,
object
.
y
)
<
max_distance
15
end
16
end
17
18
def
non_effects
19
@objects
.
reject
(
&
:effect?
)
20
end
21
end
When it comes to rendering graphics, to make an impression of randomness, we will cycle through several different damage images and draw them rotated:
09-polishing/entities/components/damage_graphics.rb
1
class
DamageGraphics
<
Component
2
def
initialize
(
object_pool
)
3
super
4
@image
=
images
.
sample
5
@angle
=
rand
(
0
.
.
360
)
6
end
7
8
def
draw
(
viewport
)
9
@image
.
draw_rot
(
x
,
y
,
0
,
@angle
)
10
end
11
12
private
13
14
def
images
15
@@images
||=
(
1
.
.
4
)
.
map
do
|
i
|
16
Gosu
::
Image
.
new
(
$window
,
17
Utils
.
media_path
(
"damage
#{
i
}
.png"
),
false
)
18
end
19
end
20
end
Explosion
will be responsible for creating Damage
instances on solid ground, just before
explosion animation starts:
class
Explosion
<
GameObject
def
initialize
(
object_pool
,
x
,
y
)
# ...
if
@object_pool
.
map
.
can_move_to?
(
x
,
y
)
Damage
.
new
(
@object_pool
,
@x
,
@y
)
end
# ...
end
# ...
end
And this is how the result looks like:
Debugging Bullet Physics
When playing the game, there is a feeling that bullets start out slow when fired and gain speed as time goes.
Let’s review BulletPhysics#update
and think why this is happening:
class
BulletPhysics
<
Component
# ...
def
update
fly_speed
=
Utils
.
adjust_speed
(
object
.
speed
)
fly_distance
=
(
Gosu
.
milliseconds
-
object
.
fired_at
)
*
0
.
001
*
fly_speed
/
2
object
.
x
,
object
.
y
=
point_at_distance
(
fly_distance
)
check_hit
object
.
explode
if
arrived?
end
# ...
end
Flaw here is very obvious. Gosu.milliseconds - object.fired_at
will be increasingly bigger as
time goes, thus increasing fly_distance
. The fix is straightforward - we want to calculate
fly_distance
using time passed between calls to BulletPhysics#update
, like this:
class
BulletPhysics
<
Component
# ...
def
update
fly_speed
=
Utils
.
adjust_speed
(
object
.
speed
)
now
=
Gosu
.
milliseconds
@last_update
||=
object
.
fired_at
fly_distance
=
(
now
-
@last_update
)
*
0
.
001
*
fly_speed
object
.
x
,
object
.
y
=
point_at_distance
(
fly_distance
)
@last_update
=
now
check_hit
object
.
explode
if
arrived?
end
# ...
end
But if you would run the game now, bullets would fly so slow, that you would feel like Neo in The Matrix. To fix that, we will have to tell our tank to fire bullets a little faster.
class
Tank
<
GameObject
# ...
def
shoot
(
target_x
,
target_y
)
if
can_shoot?
@last_shot
=
Gosu
.
milliseconds
Bullet
.
new
(
object_pool
,
@x
,
@y
,
target_x
,
target_y
)
.
fire
(
self
,
1500
)
# Old value was 100
end
end
# ...
end
Now bullets fly like they are supposed to. I can only wonder why haven’t I noticed this bug in the very beginning.
Making Camera Look Ahead
One of the most annoying things with current state of prototype is that Camera
is dragging
behind instead of showing what is in the direction you are moving. To fix the issue, we need to
change the way how Camera
moves around. First we need to know where Camera
wants to be. We will
use Utils.point_at_distance
to choose a spot ahead of the Tank
. Then, Camera#update
needs to be rewritten, so Camera
can dynamically adjust to it’s desired
spot. Here are the changes:
class
Camera
# ...
def
desired_spot
if
@target
.
physics
.
moving?
Utils
.
point_at_distance
(
@target
.
x
,
@target
.
y
,
@target
.
direction
,
@target
.
physics
.
speed
.
ceil
*
25
)
else
[
@target
.
x
,
@target
.
y
]
end
end
# ...
def
update
des_x
,
des_y
=
desired_spot
shift
=
Utils
.
adjust_speed
(
@target
.
physics
.
speed
)
.
floor
+
1
if
@x
<
des_x
if
des_x
-
@x
<
shift
@x
=
des_x
else
@x
+=
shift
end
elsif
@x
>
des_x
if
@x
-
des_x
<
shift
@x
=
des_x
else
@x
-=
shift
end
end
if
@y
<
des_y
if
des_y
-
@y
<
shift
@y
=
des_y
else
@y
+=
shift
end
elsif
@y
>
des_y
if
@y
-
des_y
<
shift
@y
=
des_y
else
@y
-=
shift
end
end
# ...
end
# ...
end
It wouldn’t win code style awards, but it does the job. Game is now much more playable.
Reviewing The Changes
Let’s get back to our list of improvements to see what we have done:
- Enemy tanks do not respawn.
- Random maps are boring and lack detail, could use more tiles or random environment objects.
- Bullets are hard to see on green surface.
- Hard to tell where enemies are coming from, radar would help.
- Sounds play at full volume even when something happens across The whole map.
- My tank should respawn after it’s dead.
- Map boundaries are visible when you come to the edge.
- Both my tank and enemies don’t have any identity. Sometimes hard to distinguish who is who.
- Explosions don’t leave a trace.
- Dead tanks keep piling up and cluttering the map.
- Camera should be scouting ahead of you when you move, not dragging behind.
- Bullets seem to accelerate.
Not bad for a start. This is what we still need to cover in next couple of chapters:
- Enemy tanks shoot at my current location, not at where I will be when bullet hits me.
- Enemy tanks don’t avoid collisions.
- Enemy tank movement patterns need polishing and improvement.
- No idea who has most kills. HUD with score and some state that displays score details would
- Would be great to have random powerups like health, extra damage.
- Motion and firing mechanics seem clumsy. help.
- Tanks could leave trails.
I will add “Optimize ObjectPool performance”, because game starts slowing down when too many
objects are added to the pool, and profiling shows that Array#select
, which is the heart of
ObjectPool#nearby
, is the main cause. Speed is one of most important features of any game, so
let’s not hesitate to improve it.
Dealing With Thousands Of Game Objects
Gosu is blazing fast when it comes to drawing, but there are more things going on. Namely, we use
ObjectPool#nearby
quite often to loop through thousands of objects 60 times per
second to measure distances among them. This slows everything down when object pool grows.
To demonstrate the effect, we will generate 1500 trees, 30 tanks, ~100 boxes and leave 1000 damage trails from explosions. It was enough to drop FPS below 30:
Spatial Partitioning
There is a solution for this particular problem is “Spatial Partitioning”, and the essence of it is that you have to use a tree-like data structure that divides space into regions, places objects there and lets you query itself in logarithmic time, omitting objects that fall out of query region. Spatial Partitioning is explained well in Game Programming Patterns.
Probably the most appropriate data structure for our 2D game is quadtree. To quote Wikipedia, “quadtrees are most often used to partition a two-dimensional space by recursively subdividing it into four quadrants or regions.” Here is how it looks like:
Implementing A Quadtree
There are some implementations of quadtree available for Ruby - rquad, rubyquadtree and rubyquad, but it seems easy to implement, so we will build one tailored (read: closely coupled) to our game using the pseudo code from Wikipedia.
Axis Aligned Bounding Box
One of prerequisites of quadtree is Axis aligned bounding box, sometimes referred to as “AABB”. It is simply a box that surrounds the shape but has edges that are in parallel with the axes of underlying coordinate system. The advantage of this box is that it gives a rough estimate where the shape is and is very efficient when it comes to querying if a point is inside or outside it.
To define axis aligned bounding box, we need it’s center point and half dimension vector, which points from center point to one of the corners of the box, and two methods, one that tells if AABB contains a point, and one that tells if AABB intersects with another AABB. This is how our implementation looks like:
10-partitioning/misc/axis_aligned_bounding_box.rb
1
class
AxisAlignedBoundingBox
2
attr_reader
:center
,
:half_dimension
3
def
initialize
(
center
,
half_dimension
)
4
@center
=
center
5
@half_dimension
=
half_dimension
6
@dhx
=
(
@half_dimension
[
0
]
-
@center
[
0
]
)
.
abs
7
@dhy
=
(
@half_dimension
[
1
]
-
@center
[
1
]
)
.
abs
8
end
9
10
def
contains?
(
point
)
11
return
false
unless
(
@center
[
0
]
+
@dhx
)
>=
point
[
0
]
12
return
false
unless
(
@center
[
0
]
-
@dhx
)
<=
point
[
0
]
13
return
false
unless
(
@center
[
1
]
+
@dhy
)
>=
point
[
1
]
14
return
false
unless
(
@center
[
1
]
-
@dhy
)
<=
point
[
1
]
15
true
16
end
17
18
def
intersects?
(
other
)
19
ocx
,
ocy
=
other
.
center
20
ohx
,
ohy
=
other
.
half_dimension
21
odhx
=
(
ohx
-
ocx
)
.
abs
22
return
false
unless
(
@center
[
0
]
+
@dhx
)
>=
(
ocx
-
odhx
)
23
return
false
unless
(
@center
[
0
]
-
@dhx
)
<=
(
ocx
+
odhx
)
24
odhy
=
(
ohy
-
ocy
)
.
abs
25
return
false
unless
(
@center
[
1
]
+
@dhy
)
>=
(
ocy
-
odhy
)
26
return
false
unless
(
@center
[
1
]
-
@dhy
)
<=
(
ocy
+
odhy
)
27
true
28
end
29
30
def
to_s
31
"c:
#{
@center
}
, h:
#{
@half_dimension
}
"
32
end
33
end
If you dig in 10-partitioning/specs
, you will find tests for this implementation too.
The math used in AxisAlignedBoundingBox#contains?
and AxisAlignedBoundingBox#intersects?
is
fairly simple and hopefully very fast, because these methods will be called billions of times
throughout the game.
QuadTree For Game Objects
To implement the glorious QuadTree
itself, we need to initialize it with boundary, that is
defined by an instance of AxisAlignedBoundingBox
and provide methods for inserting, removing and
querying the tree. Private QuadTree#subdivide
method will be called when we try to insert
an
object into a tree that has more objects than it’s NODE_CAPACITY
.
10-partitioning/misc/quad_tree.rb
1
class
QuadTree
2
NODE_CAPACITY
=
12
3
attr_accessor
:ne
,
:nw
,
:se
,
:sw
,
:objects
4
5
def
initialize
(
boundary
)
6
@boundary
=
boundary
7
@objects
=
[]
8
end
9
10
def
insert
(
game_object
)
11
return
false
unless
@boundary
.
contains?
(
12
game_object
.
location
)
13
14
if
@objects
.
size
<
NODE_CAPACITY
15
@objects
<<
game_object
16
return
true
17
end
18
19
subdivide
unless
@nw
20
21
return
true
if
@nw
.
insert
(
game_object
)
22
return
true
if
@ne
.
insert
(
game_object
)
23
return
true
if
@sw
.
insert
(
game_object
)
24
return
true
if
@se
.
insert
(
game_object
)
25
26
# should never happen
27
raise
"Failed to insert
#{
game_object
}
"
28
end
29
30
def
remove
(
game_object
)
31
return
false
unless
@boundary
.
contains?
(
32
game_object
.
location
)
33
if
@objects
.
delete
(
game_object
)
34
return
true
35
end
36
return
false
unless
@nw
37
return
true
if
@nw
.
remove
(
game_object
)
38
return
true
if
@ne
.
remove
(
game_object
)
39
return
true
if
@sw
.
remove
(
game_object
)
40
return
true
if
@se
.
remove
(
game_object
)
41
false
42
end
43
44
def
query_range
(
range
)
45
result
=
[]
46
unless
@boundary
.
intersects?
(
range
)
47
return
result
48
end
49
50
@objects
.
each
do
|
o
|
51
if
range
.
contains?
(
o
.
location
)
52
result
<<
o
53
end
54
end
55
56
# Not subdivided
57
return
result
unless
@ne
58
59
result
+=
@nw
.
query_range
(
range
)
60
result
+=
@ne
.
query_range
(
range
)
61
result
+=
@sw
.
query_range
(
range
)
62
result
+=
@se
.
query_range
(
range
)
63
64
result
65
end
66
67
private
68
69
def
subdivide
70
cx
,
cy
=
@boundary
.
center
71
hx
,
hy
=
@boundary
.
half_dimension
72
hhx
=
(
cx
-
hx
)
.
abs
/
2
.
0
73
hhy
=
(
cy
-
hy
)
.
abs
/
2
.
0
74
@nw
=
QuadTree
.
new
(
75
AxisAlignedBoundingBox
.
new
(
76
[
cx
-
hhx
,
cy
-
hhy
]
,
77
[
cx
,
cy
]
))
78
@ne
=
QuadTree
.
new
(
79
AxisAlignedBoundingBox
.
new
(
80
[
cx
+
hhx
,
cy
-
hhy
]
,
81
[
cx
,
cy
]
))
82
@sw
=
QuadTree
.
new
(
83
AxisAlignedBoundingBox
.
new
(
84
[
cx
-
hhx
,
cy
+
hhy
]
,
85
[
cx
,
cy
]
))
86
@se
=
QuadTree
.
new
(
87
AxisAlignedBoundingBox
.
new
(
88
[
cx
+
hhx
,
cy
+
hhy
]
,
89
[
cx
,
cy
]
))
90
end
91
end
This is a vanilla quadtree that stores instances of GameObject
and uses GameObject#location
for
indexing objects in space. It also has specs that you can find in code samples.
You can experiment with QuadTree#NODE_CAPACITY
, but I found that values between 8 and 16 works
best, so I settled with 12.
Integrating ObjectPool With QuadTree
We have implemented a QuadTree
, but it is not yet incorporated into our game. To do that, we will
hook it into ObjectPool
and try to keep the old interface intact, so that ObjectPool#nearby
will still work as usual, but will be able to cope with way more objects than before.
10-partitioning/entities/object_pool.rb
1
class
ObjectPool
2
attr_accessor
:map
,
:camera
,
:objects
3
4
def
size
5
@objects
.
size
6
end
7
8
def
initialize
(
box
)
9
@tree
=
QuadTree
.
new
(
box
)
10
@objects
=
[]
11
end
12
13
def
add
(
object
)
14
@objects
<<
object
15
@tree
.
insert
(
object
)
16
end
17
18
def
tree_remove
(
object
)
19
@tree
.
remove
(
object
)
20
end
21
22
def
tree_insert
(
object
)
23
@tree
.
insert
(
object
)
24
end
25
26
def
update_all
27
@objects
.
map
(
&
:update
)
28
@objects
.
reject!
do
|
o
|
29
if
o
.
removable?
30
@tree
.
remove
(
o
)
31
true
32
end
33
end
34
end
35
36
def
nearby
(
object
,
max_distance
)
37
cx
,
cy
=
object
.
location
38
hx
,
hy
=
cx
+
max_distance
,
cy
+
max_distance
39
# Fast, rough results
40
results
=
@tree
.
query_range
(
41
AxisAlignedBoundingBox
.
new
(
[
cx
,
cy
]
,
[
hx
,
hy
]
))
42
# Sift through to select fine-grained results
43
results
.
select
do
|
o
|
44
o
!=
object
&&
45
Utils
.
distance_between
(
46
o
.
x
,
o
.
y
,
object
.
x
,
object
.
y
)
<=
max_distance
47
end
48
end
49
50
def
query_range
(
box
)
51
@tree
.
query_range
(
box
)
52
end
53
end
An old fashioned array of all objects is still used, because we still need to loop through
everything and invoke GameObject#update
. ObjectPool#query_range
was introduced to quickly grab
objects that have to be rendered on screen, and ObjectPool#nearby
now queries tree and measures
distances only on rough result set.
This is how we will render things from now on:
class
PlayState
<
GameState
# ...
def
draw
cam_x
=
@camera
.
x
cam_y
=
@camera
.
y
off_x
=
$window
.
width
/
2
-
cam_x
off_y
=
$window
.
height
/
2
-
cam_y
viewport
=
@camera
.
viewport
x1
,
x2
,
y1
,
y2
=
viewport
box
=
AxisAlignedBoundingBox
.
new
(
[
x1
+
(
x2
-
x1
)
/
2
,
y1
+
(
y2
-
y1
)
/
2
]
,
[
x1
-
Map
::
TILE_SIZE
,
y1
-
Map
::
TILE_SIZE
]
)
$window
.
translate
(
off_x
,
off_y
)
do
zoom
=
@camera
.
zoom
$window
.
scale
(
zoom
,
zoom
,
cam_x
,
cam_y
)
do
@map
.
draw
(
viewport
)
@object_pool
.
query_range
(
box
)
.
map
do
|
o
|
o
.
draw
(
viewport
)
end
end
end
@camera
.
draw_crosshair
@radar
.
draw
end
# ...
end
Moving Objects In QuadTree
There is one more errand we now have to take care of. Everything works fine when things are static,
but when tanks and bullets move, we need to update them in our QuadTree
. That’s why ObjectPool
has tree_remove
and tree_insert
, which are called from GameObject#move
. From now on, the only
way to change object’s location will be by using GameObject#move
:
class
GameObject
attr_reader
:x
,
:y
,
:location
,
:components
def
initialize
(
object_pool
,
x
,
y
)
@x
,
@y
=
x
,
y
@location
=
[
x
,
y
]
@components
=
[]
@object_pool
=
object_pool
@object_pool
.
add
(
self
)
end
def
move
(
new_x
,
new_y
)
return
if
new_x
==
@x
&&
new_y
==
@y
@object_pool
.
tree_remove
(
self
)
@x
=
new_x
@y
=
new_y
@location
=
[
new_x
,
new_y
]
@object_pool
.
tree_insert
(
self
)
end
# ...
end
At this point we have to go through all the game objects and change how they initialize their base
class and update x
and y
coordinates, but we won’t cover that here. If in doubt, refer to code
at 10-partitioning
.
Finally, FPS is back to stable 60 and we can focus on gameplay again.
Implementing Powerups
Game would become more strategic if there were ways to repair your damaged tank, boost it’s speed or increase rate of fire by picking up various powerups. This should not be too difficult to implement. We will use some of these images:
For now, there will be four kinds of powerups:
- Repair damage. Wrench badge will restore damaged tank’s health back to 100 when picked up.
- Health boost. Green +1 badge will add 25 health, up to 200 total, if you keep picking them up.
- Fire boost. Double bullet badge will increase reload speed by 25%, up to 200% if you keep picking them up.
- Speed boost. Airplane badge will increase movement speed by 10%, up to 150% if you keep picking them up
These powerups will be placed randomly around the map, and will automatically respawn 30 seconds after pickup.
Implementing Base Powerup
Before rushing forward to implement this, we have to do some research and think how to elegantly
integrate this into the whole game. First, let’s agree that Powerup
is a GameObject
. It will
have graphics, sounds and it’s coordinates. Effects can by applied by harnessing
GameObject#on_collision
- when Tank
collides with Powerup
, it gets it.
11-powerups/entities/powerups/powerup.rb
1
class
Powerup
<
GameObject
2
def
initialize
(
object_pool
,
x
,
y
)
3
super
4
PowerupGraphics
.
new
(
self
,
graphics
)
5
end
6
7
def
box
8
[
x
-
8
,
y
-
8
,
9
x
+
8
,
y
-
8
,
10
x
+
8
,
y
+
8
,
11
x
-
8
,
y
+
8
]
12
end
13
14
def
on_collision
(
object
)
15
if
pickup
(
object
)
16
PowerupSounds
.
play
(
object
,
object_pool
.
camera
)
17
remove
18
end
19
end
20
21
def
pickup
(
object
)
22
# override and implement application
23
end
24
25
def
remove
26
object_pool
.
powerup_respawn_queue
.
enqueue
(
27
respawn_delay
,
28
self
.
class
,
x
,
y
)
29
mark_for_removal
30
end
31
32
def
respawn_delay
33
30
34
end
35
end
Ignore Powerup#remove
, we will get to it when implementing PowerupRespawnQueue
. The rest should be
straightforward.
Implementing Powerup Graphics
All powerups will use the same sprite sheet, so there could be a
single PowerupGraphics
class that will be rendering given sprite type. We will use
gosu-texture-packer
gem, since sprite sheet is conveniently packed already.
11-powerups/entities/components/powerup_graphics.rb
1
class
PowerupGraphics
<
Component
2
def
initialize
(
object
,
type
)
3
super
(
object
)
4
@type
=
type
5
end
6
7
def
draw
(
viewport
)
8
image
.
draw
(
x
-
12
,
y
-
12
,
1
)
9
Utils
.
mark_corners
(
object
.
box
)
if
$debug
10
end
11
12
private
13
14
def
image
15
@image
||=
images
.
frame
(
"
#{
@type
}
.png"
)
16
end
17
18
def
images
19
@@images
||=
Gosu
::
TexturePacker
.
load_json
(
20
$window
,
Utils
.
media_path
(
'pickups.json'
))
21
end
22
end
Implementing Powerup Sounds
It’s even simpler with sounds. All powerups will emit a mellow “bleep” when picked up, so
PowerupSounds
can be completely static, like ExplosionSounds
or BulletSounds
:
11-powerups/entities/components/powerup_sounds.rb
1
class
PowerupSounds
2
class
<<
self
3
def
play
(
object
,
camera
)
4
volume
,
pan
=
Utils
.
volume_and_pan
(
object
,
camera
)
5
sound
.
play
(
object
.
object_id
,
pan
,
volume
)
6
end
7
8
private
9
10
def
sound
11
@@sound
||=
StereoSample
.
new
(
12
$window
,
Utils
.
media_path
(
'powerup.mp3'
))
13
end
14
end
15
end
Implementing Repair Damage Powerup
Repairing broken tank is probably the most important powerup of them all, so let’s implement it first:
11-powerups/entities/powerups/repair_powerup.rb
1
class
RepairPowerup
<
Powerup
2
def
pickup
(
object
)
3
if
object
.
class
==
Tank
4
if
object
.
health
.
health
<
100
5
object
.
health
.
restore
6
end
7
true
8
end
9
end
10
11
def
graphics
12
:repair
13
end
14
end
This was incredibly simple. Health#restore
already existed since we had to respawn our tanks.
We can only hope other powerups are as simple to implement as this one.
Implementing Health Boost
Repairing damage is great, but how about boosting some extra health for upcoming battles? Health boost to the rescue:
11-powerups/entities/powerups/health_powerup.rb
1
class
HealthPowerup
<
Powerup
2
def
pickup
(
object
)
3
if
object
.
class
==
Tank
4
object
.
health
.
increase
(
25
)
5
true
6
end
7
end
8
9
def
graphics
10
:life_up
11
end
12
end
This time we have to implement Health#increase
, but it is pretty simple:
class
Health
<
Component
# ...
def
increase
(
amount
)
@health
=
[
@health
+
25
,
@initial_health
*
2
].
min
@health_updated
=
true
end
# ...
end
Since Tank
has @initial_health
equal to 100, increasing health won’t go over 200, which is
exactly what we want.
Implementing Fire Rate Boost
How about boosting tank’s fire rate?
11-powerups/entities/powerups/fire_rate_powerup.rb
1
class
FireRatePowerup
<
Powerup
2
def
pickup
(
object
)
3
if
object
.
class
==
Tank
4
if
object
.
fire_rate_modifier
<
2
5
object
.
fire_rate_modifier
+=
0
.
25
6
end
7
true
8
end
9
end
10
11
def
graphics
12
:straight_gun
13
end
14
end
We need to introduce @fire_rate_modifier
in Tank
class and use it when calling
Tank#can_shoot?
:
class
Tank
<
GameObject
# ...
attr_accessor
:fire_rate_modifier
# ...
def
can_shoot?
Gosu
.
milliseconds
-
(
@last_shot
||
0
)
>
(
SHOOT_DELAY
/
@fire_rate_modifier
)
end
# ...
def
reset_modifiers
@fire_rate_modifier
=
1
end
# ...
end
Tank#reset_modifier
should be called when respawning, since we want tanks to lose their powerups
when they die. It can be done in TankHealth#after_death
:
class
TankHealth
<
Health
# ...
def
after_death
object
.
reset_modifiers
# ...
end
end
Implementing Tank Speed Boost
Tank speed boost is very similar to fire rate powerup:
11-powerups/entities/powerups/tank_speed_powerup.rb
1
class
TankSpeedPowerup
<
Powerup
2
def
pickup
(
object
)
3
if
object
.
class
==
Tank
4
if
object
.
speed_modifier
<
1
.
5
5
object
.
speed_modifier
+=
0
.
10
6
end
7
true
8
end
9
end
10
11
def
graphics
12
:wingman
13
end
14
end
We have to add @speed_modifier
to Tank
class and use it in TankPhysics#update
when calculating
movement distance.
# 11-powerups/entities/tank.rb
class
Tank
<
GameObject
# ...
attr_accessor
:speed_modifier
# ...
def
reset_modifiers
# ...
@speed_modifier
=
1
end
# ...
end
# 11-powerups/entities/components/tank_physics.rb
class
TankPhysics
<
Component
# ...
def
update
# ...
new_x
,
new_y
=
x
,
y
speed
=
apply_movement_penalty
(
@speed
)
shift
=
Utils
.
adjust_speed
(
speed
)
*
object
.
speed_modifier
# ...
end
# ...
end
Camera#update
has also refer to Tank#speed_modifier
, otherwise the operator will fail to catch
up and camera will be lagging behind.
class
Camera
# ...
def
update
# ...
shift
=
Utils
.
adjust_speed
(
@target
.
physics
.
speed
)
.
floor
*
@target
.
speed_modifier
+
1
# ...
end
# ...
end
Spawning Powerups On Map
Powerups are implemented, but not yet spawned. We will spawn 20 - 30 random powerups when generating the map:
class
Map
# ...
def
initialize
(
object_pool
)
# ...
generate_powerups
end
# ...
def
generate_powerups
pups
=
0
target_pups
=
rand
(
20
.
.
30
)
while
pups
<
target_pups
do
x
=
rand
(
0
.
.
MAP_WIDTH
*
TILE_SIZE
)
y
=
rand
(
0
.
.
MAP_HEIGHT
*
TILE_SIZE
)
if
tile_at
(
x
,
y
)
!=
@water
random_powerup
.
new
(
@object_pool
,
x
,
y
)
pups
+=
1
end
end
end
def
random_powerup
[
HealthPowerup
,
RepairPowerup
,
FireRatePowerup
,
TankSpeedPowerup
].
sample
end
# ...
end
The code is very similar to generating boxes. It’s probably not the best way to distribute powerups on map, but it will have to do for now.
Respawning Powerups After Pickup
When we pick up a powerup, we want it to reappear in same spot 30 seconds later. A thought “we can
start a new Thread
with sleep
and initialize the same powerup there” sounds very bad, but I had
it for a few seconds. Then PowerupRespawnQueue
was born.
First, let’s recall how Powerup#remove
method looks like:
class
Powerup
<
GameObject
# ...
def
remove
object_pool
.
powerup_respawn_queue
.
enqueue
(
respawn_delay
,
self
.
class
,
x
,
y
)
mark_for_removal
end
# ...
end
Powerup enqueues itself for respawn when picked up, providing it’s class and coordinates.
PowerupRespawnQueue
holds this data and respawns powerups at right time with help of
ObjectPool
:
11-powerups/entities/powerups/powerup_respawn_queue.rb
1
class
PowerupRespawnQueue
2
RESPAWN_DELAY
=
1000
3
def
initialize
4
@respawn_queue
=
{}
5
@last_respawn
=
Gosu
.
milliseconds
6
end
7
8
def
enqueue
(
delay_seconds
,
type
,
x
,
y
)
9
respawn_at
=
Gosu
.
milliseconds
+
delay_seconds
*
1000
10
@respawn_queue
[
respawn_at
.
to_i
]
=
[
type
,
x
,
y
]
11
end
12
13
def
respawn
(
object_pool
)
14
now
=
Gosu
.
milliseconds
15
return
if
now
-
@last_respawn
<
RESPAWN_DELAY
16
@respawn_queue
.
keys
.
each
do
|
k
|
17
next
if
k
>
now
# not yet
18
type
,
x
,
y
=
@respawn_queue
.
delete
(
k
)
19
type
.
new
(
object_pool
,
x
,
y
)
20
end
21
@last_respawn
=
now
22
end
23
end
PowerupRespawnQeueue#respawn
is called from ObjectPool#update_all
, but is throttled to run once per second for better performance.
class
ObjectPool
# ...
attr_accessor
:powerup_respawn_queue
# ...
def
update_all
# ...
@powerup_respawn_queue
.
respawn
(
self
)
end
# ...
end
This is it, the game should now contain randomly placed powerups that respawn 30 seconds after picked up. Time to enjoy the result.
We haven’t done any changes to AI though, that means enemies will only be picking those powerups by accident, so now you have a significant advantage and the game has suddenly became too easy to play. Don’t worry, we will be fixing that when overhauling the AI.
Implementing Heads Up Display
In order to know what’s happening, we need some sort of HUD. We already have crosshair and radar, but they are scattered around in code. Now we want to display active powerup modifiers, so you would know what is your fire rate and speed, and if it’s worth getting one more powerup before going into the next fight.
Design Considerations
While creating our HUD class, we will have to start building game stats, because we want to display
number of kills our tank made. Stats topic will be covered in depth later, but for now let’s assume
that @tank.input.stats.kills
gives us the kill count, which we want to draw in top-left corner of
the screen, along with player health and modifier values.
HUD will also be responsible for drawing crosshair and radar.
Rendering Text With Custom Font
Previously, all text were rendered with Gosu.default_font_name
, and we want something more fancy
and more thematic, probably a dirty stencil based font like this
one:
And one more fancy font will make our game title look good. Too bad we don’t have a title yet, but “Tanks Prototype” writen in a thematic way still looks pretty good.
To have convenient access to these fonts, we will add a helper methods in Utils
:
module
Utils
# ...
def
self
.
title_font
media_path
(
'top_secret.ttf'
)
end
def
self
.
main_font
media_path
(
'armalite_rifle.ttf'
)
end
# ...
end
Use it instead of Gosu.default_font_name
:
size
=
20
Gosu
::
Image
.
from_text
(
$window
,
"Your text"
,
Utils
.
main_font
,
size
)
Implementing HUD Class
After we have put everything together, we will get HUD
class:
12-stats/entities/hud.rb
1
class
HUD
2
attr_accessor
:active
3
def
initialize
(
object_pool
,
tank
)
4
@object_pool
=
object_pool
5
@tank
=
tank
6
@radar
=
Radar
.
new
(
@object_pool
,
tank
)
7
end
8
9
def
player
=
(
tank
)
10
@tank
=
tank
11
@radar
.
target
=
tank
12
end
13
14
def
update
15
@radar
.
update
16
end
17
18
def
health_image
19
if
@health
.
nil?
||
@tank
.
health
.
health
!=
@health
20
@health
=
@tank
.
health
.
health
21
@health_image
=
Gosu
::
Image
.
from_text
(
22
$window
,
"Health:
#{
@health
}
"
,
Utils
.
main_font
,
20
)
23
end
24
@health_image
25
end
26
27
def
stats_image
28
stats
=
@tank
.
input
.
stats
29
if
@stats_image
.
nil?
||
stats
.
changed_at
<=
Gosu
.
milliseconds
30
@stats_image
=
Gosu
::
Image
.
from_text
(
31
$window
,
"Kills:
#{
stats
.
kills
}
"
,
Utils
.
main_font
,
20
)
32
end
33
@stats_image
34
end
35
36
def
fire_rate_image
37
if
@tank
.
fire_rate_modifier
>
1
38
if
@fire_rate
!=
@tank
.
fire_rate_modifier
39
@fire_rate
=
@tank
.
fire_rate_modifier
40
@fire_rate_image
=
Gosu
::
Image
.
from_text
(
41
$window
,
"Fire rate:
#{
@fire_rate
.
round
(
2
)
}
X"
,
42
Utils
.
main_font
,
20
)
43
end
44
else
45
@fire_rate_image
=
nil
46
end
47
@fire_rate_image
48
end
49
50
def
speed_image
51
if
@tank
.
speed_modifier
>
1
52
if
@speed
!=
@tank
.
speed_modifier
53
@speed
=
@tank
.
speed_modifier
54
@speed_image
=
Gosu
::
Image
.
from_text
(
55
$window
,
"Speed:
#{
@speed
.
round
(
2
)
}
X"
,
56
Utils
.
main_font
,
20
)
57
end
58
else
59
@speed_image
=
nil
60
end
61
@speed_image
62
end
63
64
def
draw
65
if
@active
66
@object_pool
.
camera
.
draw_crosshair
67
end
68
@radar
.
draw
69
offset
=
20
70
health_image
.
draw
(
20
,
offset
,
1000
)
71
stats_image
.
draw
(
20
,
offset
+=
30
,
1000
)
72
if
fire_rate_image
73
fire_rate_image
.
draw
(
20
,
offset
+=
30
,
1000
)
74
end
75
if
speed_image
76
speed_image
.
draw
(
20
,
offset
+=
30
,
1000
)
77
end
78
end
79
end
To use it, we need to hook into PlayState
:
class
PlayState
<
GameState
# ...
def
initialize
# ...
@hud
=
HUD
.
new
(
@object_pool
,
@tank
)
end
def
update
# ...
@hud
.
update
end
def
draw
# ...
@hud
.
draw
end
# ...
end
Assuming you have mocked @tank.input.stats.kills
in HUD
, you should get a neat view showing
interesting things in top-left corner of the screen:
Implementing Game Statistics
Games like one we are building are all about competition, and you cannot compete if you don’t know the score. Let us introduce a class that will be responsible for keeping tabs on various statistics of every tank.
12-stats/misc/stats.rb
1
class
Stats
2
attr_reader
:name
,
:kills
,
:deaths
,
:shots
,
:changed_at
3
def
initialize
(
name
)
4
@name
=
name
5
@kills
=
@deaths
=
@shots
=
@damage
=
@damage_dealt
=
0
6
changed
7
end
8
9
def
add_kill
(
amount
=
1
)
10
@kills
+=
amount
11
changed
12
end
13
14
def
add_death
15
@deaths
+=
1
16
changed
17
end
18
19
def
add_shot
20
@shots
+=
1
21
changed
22
end
23
24
def
add_damage
(
amount
)
25
@damage
+=
amount
26
changed
27
end
28
29
def
damage
30
@damage
.
round
31
end
32
33
def
add_damage_dealt
(
amount
)
34
@damage_dealt
+=
amount
35
changed
36
end
37
38
def
damage_dealt
39
@damage_dealt
.
round
40
end
41
42
def
to_s
43
"[kills:
#{
@kills
}
, "
\
44
"deaths:
#{
@deaths
}
, "
\
45
"shots:
#{
@shots
}
, "
\
46
"damage:
#{
damage
}
, "
\
47
"damage_dealt:
#{
damage_dealt
}
]"
48
end
49
50
private
51
52
def
changed
53
@changed_at
=
Gosu
.
milliseconds
54
end
55
end
While building the HUD
, we established that Stats
should belong to Tank#input
, because it
defines who is controlling the tank. So, every instance of PlayerInput
and AiInput
has to have
it’s own Stats
:
# 12-stats/entities/components/player_input.rb
class
PlayerInput
<
Component
# ...
attr_reader
:stats
def
initialize
(
name
,
camera
,
object_pool
)
# ...
@stats
=
Stats
.
new
(
name
)
end
# ...
def
on_damage
(
amount
)
@stats
.
add_damage
(
amount
)
end
# ...
end
# 12-stats/entities/components/ai_input.rb
class
AiInput
<
Component
# ...
attr_reader
:stats
def
initialize
(
name
,
object_pool
)
# ...
@stats
=
Stats
.
new
(
name
)
end
def
on_damage
(
amount
)
# ...
@stats
.
add_damage
(
amount
)
end
end
That itch to extract a base class from PlayerInput
and AiInput
is getting stronger, but we will
have to resist the urge, for now.
Tracking Kills, Deaths and Damage
To begin tracking kills, we need to know whom does every bullet belong to. Bullet
already has
source
attribute, which contains the tank that fired it, there will be no trouble to find out who
was the shooter when bullet gets a direct hit. But how about explosions? Bullets that hit the
ground nearby a tank deals indirect damage from the explosion.
Solution is simple, we need to pass the source
of the Bullet
to the Explosion
when it’s being
initialized.
class
Bullet
<
GameObject
# ...
def
explode
Explosion
.
new
(
object_pool
,
@x
,
@y
,
@source
)
# ...
end
# ...
end
Making Damage Personal
Now that we have the source of every Bullet
and Explosion
they trigger, we can start passing
the cause of damage to Health#inflict_damage
and incrementing the appropriate stats.
# 12-stats/entities/components/health.rb
class
Health
<
Component
# ...
def
inflict_damage
(
amount
,
cause
)
if
@health
>
0
@health_updated
=
true
if
object
.
respond_to?
(
:input
)
object
.
input
.
stats
.
add_damage
(
amount
)
# Don't count damage to trees and boxes
if
cause
.
respond_to?
(
:input
)
&&
cause
!=
object
cause
.
input
.
stats
.
add_damage_dealt
(
amount
)
end
end
@health
=
[
@health
-
amount
.
to_i
,
0
].
max
after_death
(
cause
)
if
dead?
end
end
# ...
end
# 12-stats/entities/components/tank_health.rb
class
TankHealth
<
Health
# ...
def
after_death
(
cause
)
# ...
object
.
input
.
stats
.
add_death
kill
=
object
!=
cause
?
1
:
-
1
cause
.
input
.
stats
.
add_kill
(
kill
)
# ...
end
# ...
end
Tracking Damage From Chain Reactions
There is one more way to cause damage. When you shoot a tree, box or barrel, it explodes, probably triggering a chain reaction of explosions around it. If those explosions kill somebody, it would only be fair to account that kill for the tank that triggered this chain reaction.
To solve this, simply pass the cause of death to the Explosion
that gets triggered afterwards.
# 12-stats/entities/components/health.rb
class
Health
<
Component
# ...
def
after_death
(
cause
)
if
@explodes
Thread
.
new
do
# ...
Explosion
.
new
(
@object_pool
,
x
,
y
,
cause
)
# ...
end
# ...
end
end
end
# 12-stats/entities/components/tank_health.rb
class
TankHealth
<
Health
# ...
def
after_death
(
cause
)
# ...
Thread
.
new
do
# ...
Explosion
.
new
(
@object_pool
,
x
,
y
,
cause
)
end
end
end
Now every bit of damage gets accounted for.
Displaying Game Score
Having all the data is useless unless we display it somehow. For this, let’s rethink our game
states. Now we have MenuState
and PlayState
. Both of them can switch one into another. What if
we introduced a PauseState
, which would freeze the game and display the list of all tanks along
with their kills. Then MenuState
would switch to PlayState
, and from PlayState
you would be
able to get to PauseState
.
Let’s begin by implementing ScoreDisplay
, that would print a sorted list of tank kills along with
their names.
12-stats/entities/score_display.rb
1
class
ScoreDisplay
2
def
initialize
(
object_pool
)
3
tanks
=
object_pool
.
objects
.
select
do
|
o
|
4
o
.
class
==
Tank
5
end
6
stats
=
tanks
.
map
(
&
:input
)
.
map
(
&
:stats
)
7
stats
.
sort!
do
|
stat1
,
stat2
|
8
stat2
.
kills
<=>
stat1
.
kills
9
end
10
create_stats_image
(
stats
)
11
end
12
13
def
create_stats_image
(
stats
)
14
text
=
stats
.
map
do
|
stat
|
15
"
#{
stat
.
kills
}
:
#{
stat
.
name
}
"
16
end
.
join
(
"
\n
"
)
17
@stats_image
=
Gosu
::
Image
.
from_text
(
18
$window
,
text
,
Utils
.
main_font
,
30
)
19
end
20
21
def
draw
22
@stats_image
.
draw
(
23
$window
.
width
/
2
-
@stats_image
.
width
/
2
,
24
$window
.
height
/
4
+
30
,
25
1000
)
26
end
27
end
We will have to initialize ScoreDisplay
every time when we want to show the updated score. Time
to create the PauseState
that would show the score.
12-stats/game_states/pause_state.rb
1
require
'singleton'
2
class
PauseState
<
GameState
3
include
Singleton
4
attr_accessor
:play_state
5
6
def
initialize
7
@message
=
Gosu
::
Image
.
from_text
(
8
$window
,
"Game Paused"
,
9
Utils
.
title_font
,
60
)
10
end
11
12
def
enter
13
music
.
play
(
true
)
14
music
.
volume
=
1
15
@score_display
=
ScoreDisplay
.
new
(
@play_state
.
object_pool
)
16
@mouse_coords
=
[
$window
.
mouse_x
,
$window
.
mouse_y
]
17
end
18
19
def
leave
20
music
.
volume
=
0
21
music
.
stop
22
$window
.
mouse_x
,
$window
.
mouse_y
=
@mouse_coords
23
end
24
25
def
music
26
@@music
||=
Gosu
::
Song
.
new
(
27
$window
,
Utils
.
media_path
(
'menu_music.mp3'
))
28
end
29
30
def
draw
31
@play_state
.
draw
32
@message
.
draw
(
33
$window
.
width
/
2
-
@message
.
width
/
2
,
34
$window
.
height
/
4
-
@message
.
height
,
35
1000
)
36
@score_display
.
draw
37
end
38
39
def
button_down
(
id
)
40
$window
.
close
if
id
==
Gosu
::
KbQ
41
if
id
==
Gosu
::
KbC
&&
@play_state
42
GameState
.
switch
(
@play_state
)
43
end
44
if
id
==
Gosu
::
KbEscape
45
GameState
.
switch
(
@play_state
)
46
end
47
end
48
end
You will notice that PauseState
invokes PlayState#draw
, but without PlayState#update
this
will be a still image. We make sure we hide the crosshair and restore previous mouse location when
resuming play state. That way player would not be able to cheat by pausing the game, targeting the
tank while nothing moves and then unpausing ready to deal damage. Our HUD
had attr_accessor
:active
exactly for this reason, but we need to switch it on and off in PlayState#enter
and
PlayState#leave
.
class
PlayState
<
GameState
# ...
def
button_down
(
id
)
# ...
if
id
==
Gosu
::
KbEscape
pause
=
PauseState
.
instance
pause
.
play_state
=
self
GameState
.
switch
(
pause
)
end
# ...
end
# ...
def
leave
StereoSample
.
stop_all
@hud
.
active
=
false
end
def
enter
@hud
.
active
=
true
end
# ...
end
Time for a test drive.
For now, scoring most kills is relatively simple. This should change when we will tell enemy AI to collect powerups when appropriate.
Building Advanced AI
The AI we have right now can kick some ass, but it is too dumb for any seasoned gamer to compete with. This is the list of current flaws:
- It does not navigate well, gets stuck among trees or somewhere near water.
- It is not aware of powerups.
- It could do better job at shooting.
- It’s field of vision is too small, compared to player’s, who is equipped with radar.
We will tackle these issues in current chapter.
Improving Tank Navigation
Tanks shouldn’t behave like Roombas, randomly driving around and bumping into things. They could be navigating like this:
- Consult with current AI state and find or update destination point.
- If destination has changed, calculate shortest path to destination.
- Move along the calculated path.
- Repeat.
If this looks easy, let me assure you, it would probably require rewriting the majority of AI
and
Map
code we have at this point, and it is pretty tricky to implement with procedurally generated
maps, because normally you would use a map editor to set up waypoints, navigation mesh or other
hints for AI so it doesn’t get stuck. Sometimes it is better to have something working imperfectly
over a perfect solution that never happens, thus we will use simple things that will make as much
impact as possible without rewriting half of the code.
Generating Friendlier Maps
One of main reasons why tanks get stuck is bad placement of spawn points. They don’t take trees and
boxes into account, so enemy tank can spawn in the middle of a forest, with no chance of getting
out without blowing things up. A simple fix would be to consult with ObjectPool
before placing a
spawn point only where there are no other game objects around in, say, 150 pixel radius:
class
Map
# ...
def
find_spawn_point
while
true
x
=
rand
(
0
.
.
MAP_WIDTH
*
TILE_SIZE
)
y
=
rand
(
0
.
.
MAP_HEIGHT
*
TILE_SIZE
)
if
can_move_to?
(
x
,
y
)
&&
@object_pool
.
nearby_point
(
x
,
y
,
150
)
.
empty?
return
[
x
,
y
]
end
end
end
# ...
end
How about powerups? They can also spawn in the middle of a forest, and while tanks are not seeking them yet, we will be implementing this behavior, and leading tanks into wilderness of trees is not the best idea ever. Let’s fix it too:
class
Map
# ...
def
generate_powerups
pups
=
0
target_pups
=
rand
(
20
.
.
30
)
while
pups
<
target_pups
do
x
=
rand
(
0
.
.
MAP_WIDTH
*
TILE_SIZE
)
y
=
rand
(
0
.
.
MAP_HEIGHT
*
TILE_SIZE
)
if
tile_at
(
x
,
y
)
!=
@water
&&
@object_pool
.
nearby_point
(
x
,
y
,
150
)
.
empty?
random_powerup
.
new
(
@object_pool
,
x
,
y
)
pups
+=
1
end
end
end
# ...
end
We could also reduce tree count, but that would make the map look worse, so we are going to keep this in our pocket as a mean of last resort.
Implementing Demo State To Observe AI
Probably the best way to figure out if our AI is any good is to target one of AI tanks with our
game camera and see how it plays. It will give us a great visual testing tool that will allow
tweaking AI settings and seeing if they perform better or worse. For that we will introduce
DemoState
where only AI tanks will be present in the map, and we will be able to switch camera
from one tank to another.
DemoState
is very similar to PlayState
, the main difference is that there is no player. We will
extract create_tanks
method that will be overridden in DemoState
.
class
PlayState
<
GameState
attr_accessor
:update_interval
,
:object_pool
,
:tank
def
initialize
# ...
@camera
=
Camera
.
new
@object_pool
.
camera
=
@camera
create_tanks
(
4
)
end
# ...
private
def
create_tanks
(
amount
)
@map
.
spawn_points
(
amount
*
3
)
@tank
=
Tank
.
new
(
@object_pool
,
PlayerInput
.
new
(
'Player'
,
@camera
,
@object_pool
))
amount
.
times
do
|
i
|
Tank
.
new
(
@object_pool
,
AiInput
.
new
(
@names
.
random
,
@object_pool
))
end
@camera
.
target
=
@tank
@hud
=
HUD
.
new
(
@object_pool
,
@tank
)
end
# ...
end
We will also want to display a smaller version of score in top-right corner of the screen, so let’s
add some adjustments to ScoreDisplay
:
class
ScoreDisplay
def
initialize
(
object_pool
,
font_size
=
30
)
@font_size
=
font_size
# ...
end
def
create_stats_image
(
stats
)
# ...
@stats_image
=
Gosu
::
Image
.
from_text
(
$window
,
text
,
Utils
.
main_font
,
@font_size
)
end
# ...
def
draw_top_right
@stats_image
.
draw
(
$window
.
width
-
@stats_image
.
width
-
20
,
20
,
1000
)
end
end
And here is the extended DemoState
:
13-advanced-ai/game_states/demo_state.rb
1
class
DemoState
<
PlayState
2
attr_accessor
:tank
3
4
def
enter
5
# Prevent reactivating HUD
6
end
7
8
def
update
9
super
10
@score_display
=
ScoreDisplay
.
new
(
11
object_pool
,
20
)
12
end
13
14
def
draw
15
super
16
@score_display
.
draw_top_right
17
end
18
19
def
button_down
(
id
)
20
super
21
if
id
==
Gosu
::
KbSpace
22
target_tank
=
@tanks
.
reject
do
|
t
|
23
t
==
@camera
.
target
24
end
.
sample
25
switch_to_tank
(
target_tank
)
26
end
27
end
28
29
private
30
31
def
create_tanks
(
amount
)
32
@map
.
spawn_points
(
amount
*
3
)
33
@tanks
=
[]
34
amount
.
times
do
|
i
|
35
@tanks
<<
Tank
.
new
(
@object_pool
,
AiInput
.
new
(
36
@names
.
random
,
@object_pool
))
37
end
38
target_tank
=
@tanks
.
sample
39
@hud
=
HUD
.
new
(
@object_pool
,
target_tank
)
40
@hud
.
active
=
false
41
switch_to_tank
(
target_tank
)
42
end
43
44
def
switch_to_tank
(
tank
)
45
@camera
.
target
=
tank
46
@hud
.
player
=
tank
47
self
.
tank
=
tank
48
end
49
end
To have a possibility to enter DemoState
, we need to change MenuState
a little:
class
MenuState
<
GameState
# ...
def
update
text
=
"Q: Quit
\n
N: New Game
\n
D: Demo"
# ...
end
# ...
def
button_down
(
id
)
# ...
if
id
==
Gosu
::
KbD
@play_state
=
DemoState
.
new
GameState
.
switch
(
@play_state
)
end
end
end
Now, main menu has the option to enter demo state:
Visual AI Debugging
After watching AI behavior in demo mode for a while, I was terrified. When playing game normally, you usually see tanks in “fighting” state, which works pretty well, but when tanks go roaming, it’s a complete disaster. They get stuck easily, they don’t go too far from the original location, they wait too much.
Some things could be improved just by changing wait_time
, turn_time
and drive_time
to
different values, but we certainly have to do bigger changes than that.
On the other hand, “observe AI in action, tweak, repeat” cycle proved to be very effective, I will definitely use this technique in all my future games.
To make visual debugging easier, build yourself some tooling. One way to do it is to have global
$debug
variable which you can toggle by pressing some button:
class
PlayState
<
GameState
# ...
def
button_down
(
id
)
# ...
if
id
==
Gosu
::
KbF1
$debug
=
!
$debug
end
# ...
end
# ...
end
Then add extra drawing instructions to your objects and their components. For example, this will
make Tank
display it’s current TankMotionState
implementation class beneath it:
class
TankMotionFSM
# ...
def
set_state
(
state
)
# ...
if
$debug
@image
=
Gosu
::
Image
.
from_text
(
$window
,
state
.
class
.
to_s
,
Gosu
.
default_font_name
,
18
)
end
end
# ...
def
draw
(
viewport
)
if
$debug
@image
&&
@image
.
draw
(
@object
.
x
-
@image
.
width
/
2
,
@object
.
y
+
@object
.
graphics
.
height
/
2
-
@image
.
height
,
100
)
end
end
# ...
end
To mark tank’s desired gun angle as blue line and actual gun angle as red line, you can do this:
class
AiGun
# ...
def
draw
(
viewport
)
if
$debug
color
=
Gosu
::
Color
::
BLUE
x
,
y
=
@object
.
x
,
@object
.
y
t_x
,
t_y
=
Utils
.
point_at_distance
(
x
,
y
,
@desired_gun_angle
,
BulletPhysics
::
MAX_DIST
)
$window
.
draw_line
(
x
,
y
,
color
,
t_x
,
t_y
,
color
,
1001
)
color
=
Gosu
::
Color
::
RED
t_x
,
t_y
=
Utils
.
point_at_distance
(
x
,
y
,
@object
.
gun_angle
,
BulletPhysics
::
MAX_DIST
)
$window
.
draw_line
(
x
,
y
,
color
,
t_x
,
t_y
,
color
,
1000
)
end
end
# ...
end
Finally, you can automatically mark collision box corners on your graphics components. Let’s take
BoxGraphics
for example:
# 13-advanced-ai/misc/utils.rb
module
Utils
# ...
def
self
.
mark_corners
(
box
)
i
=
0
box
.
each_slice
(
2
)
do
|
x
,
y
|
color
=
DEBUG_COLORS
[
i
]
$window
.
draw_triangle
(
x
-
3
,
y
-
3
,
color
,
x
,
y
,
color
,
x
+
3
,
y
-
3
,
color
,
100
)
i
=
(
i
+
1
)
%
4
end
end
# ...
end
# 13-advanced-ai/entities/components/box_graphics.rb
class
BoxGraphics
<
Component
# ..
def
draw
(
viewport
)
@box
.
draw_rot
(
x
,
y
,
0
,
object
.
angle
)
Utils
.
mark_corners
(
object
.
box
)
if
$debug
end
# ...
end
As a developer, you can make yourself see nearly everything you want, make use of it.
Although it hurts the framerate a little, it is very useful when building not only AI, but the
rest of the game too. Using this visual debugging together with Demo mode, you can tweak all the AI
values to make it shoot more often, fight better, and be more agile. We won’t go through this
minor tuning, but you can find the changes by viewing changes introduced in 13-advanced-ai
.
Making AI Collect Powerups
To even out the odds, we have to make AI seek powerups when they are required. The logic behind it can be implemented using a couple of simple steps:
- AI would know what powerups are currently needed. This may vary from state to state, i.e. speed and fire rate powerups are nice to have when roaming, but not that important when fleeing after taking heavy damage. And we don’t want AI to waste time and collect speed powerups when speed modifier is already maxed out.
-
AiVision
would return closest visible powerup, filtered by acceptable powerup types. - Some
TankMotionState
implementation would adjust tank direction towards closest visible powerup inchange_direction
method.
Finding Powerups In Sight
To implement changes in AiVision
, we will introduce closest_powerup
method. It will query
objects in sight and filter them out by their class and distance.
class
AiVision
# ...
POWERUP_CACHE_TIMEOUT
=
50
# ...
def
closest_powerup
(
*
suitable
)
now
=
Gosu
.
milliseconds
@closest_powerup
=
nil
if
now
-
(
@powerup_cache_updated_at
||=
0
)
>
POWERUP_CACHE_TIMEOUT
@closest_powerup
=
nil
@powerup_cache_updated_at
=
now
end
@closest_powerup
||=
find_closest_powerup
(
*
suitable
)
end
private
def
find_closest_powerup
(
*
suitable
)
if
suitable
.
empty?
suitable
=
[
FireRatePowerup
,
HealthPowerup
,
RepairPowerup
,
TankSpeedPowerup
]
end
@in_sight
.
select
do
|
o
|
suitable
.
include?
(
o
.
class
)
end
.
sort
do
|
a
,
b
|
x
,
y
=
@viewer
.
x
,
@viewer
.
y
d1
=
Utils
.
distance_between
(
x
,
y
,
a
.
x
,
a
.
y
)
d2
=
Utils
.
distance_between
(
x
,
y
,
b
.
x
,
b
.
y
)
d1
<=>
d2
end
.
first
end
# ...
end
It is very similar to AiVision#closest_tank
, and parts should probably be extracted to keep the
code dry, but we will not bother.
Seeking Powerups While Roaming
Roaming is when most picking should happen, because Tank sees no enemies in sight and needs to
prepare for upcoming battles. Let’s see how can we implement this behavior while leveraging the
newly made AiVision#closest_powerup
:
class
TankRoamingState
<
TankMotionState
# ...
def
required_powerups
required
=
[]
health
=
@object
.
health
.
health
if
@object
.
fire_rate_modifier
<
2
&&
health
>
50
required
<<
FireRatePowerup
end
if
@object
.
speed_modifier
<
1
.
5
&&
health
>
50
required
<<
TankSpeedPowerup
end
if
health
<
100
required
<<
RepairPowerup
end
if
health
<
190
required
<<
HealthPowerup
end
required
end
def
change_direction
closest_powerup
=
@vision
.
closest_powerup
(
*
required_powerups
)
if
closest_powerup
@seeking_powerup
=
true
angle
=
Utils
.
angle_between
(
@object
.
x
,
@object
.
y
,
closest_powerup
.
x
,
closest_powerup
.
y
)
@object
.
physics
.
change_direction
(
angle
-
angle
%
45
)
else
@seeking_powerup
=
false
# ... choose random direction
end
@changed_direction_at
=
Gosu
.
milliseconds
@will_keep_direction_for
=
turn_time
end
# ...
def
turn_time
if
@seeking_powerup
rand
(
100
.
.
300
)
else
rand
(
1000
.
.
3000
)
end
end
end
It is simple as that, and our AI tanks are now getting buffed on their spare time.
Seeking Health Powerups After Heavy Damage
To seek health when damaged, we need to change TankFleeingState#change_direction
:
class
TankFleeingState
<
TankMotionState
# ...
def
change_direction
closest_powerup
=
@vision
.
closest_powerup
(
RepairPowerup
,
HealthPowerup
)
if
closest_powerup
angle
=
Utils
.
angle_between
(
@object
.
x
,
@object
.
y
,
closest_powerup
.
x
,
closest_powerup
.
y
)
@object
.
physics
.
change_direction
(
angle
-
angle
%
45
)
else
# ... reverse from enemy
end
@changed_direction_at
=
Gosu
.
milliseconds
@will_keep_direction_for
=
turn_time
end
# ...
end
This small change tells AI to pick up health while fleeing. The interesting part is that when tank
picks up RepairPowerup
, it’s health gets fully restored and AI should switch back to
TankFightingState
. This simple thing is a major improvement in AI behavior.
Evading Collisions And Getting Unstuck
While observing AI navigation, it was noticeable that tanks often got stuck, even in simple
situations, like driving into a tree and hitting it repeatedly for a dozen of seconds. To reduce
the number of such occasions, we will introduce TankNavigatingState
, which would help avoid
collisions, and TankStuckState
, which would be responsible for driving out of dead ends as
quickly as possible.
To implement these states, we need to have a way to tell if tank can go forward and a way of
getting a direction which is not blocked by other objects. Let’s add a couple of methods to
AiVision
:
class
AiVision
# ...
def
can_go_forward?
in_front
=
Utils
.
point_at_distance
(
*
@viewer
.
location
,
@viewer
.
direction
,
40
)
@object_pool
.
map
.
can_move_to?
(
*
in_front
)
&&
@object_pool
.
nearby_point
(
*
in_front
,
40
,
@viewer
)
.
reject
{
|
o
|
o
.
is_a?
Powerup
}
.
empty?
end
def
closest_free_path
(
away_from
=
nil
)
paths
=
[]
5
.
times
do
|
i
|
if
paths
.
any?
return
farthest_from
(
paths
,
away_from
)
end
radius
=
55
-
i
*
5
range_x
=
range_y
=
[-
radius
,
0
,
radius
]
range_x
.
shuffle
.
each
do
|
x
|
range_y
.
shuffle
.
each
do
|
y
|
x
=
@viewer
.
x
+
x
y
=
@viewer
.
y
+
y
if
@object_pool
.
map
.
can_move_to?
(
x
,
y
)
&&
@object_pool
.
nearby_point
(
x
,
y
,
radius
,
@viewer
)
.
reject
{
|
o
|
o
.
is_a?
Powerup
}
.
empty?
if
away_from
paths
<<
[
x
,
y
]
else
return
[
x
,
y
]
end
end
end
end
end
false
end
alias
:closest_free_path_away_from
:closest_free_path
# ...
private
def
farthest_from
(
paths
,
away_from
)
paths
.
sort
do
|
p1
,
p2
|
Utils
.
distance_between
(
*
p1
,
*
away_from
)
<=>
Utils
.
distance_between
(
*
p2
,
*
away_from
)
end
.
first
end
# ...
end
AiVision#can_go_forward?
tells if tank can move ahead, and AiVision#closest_free_path
finds a
point where tank can move without obstacles. You can also call
AiVision#closest_free_path_away_from
and provide coordinates you are trying to get away from.
We will use closest_free_path
methods in newly implemented tank motion states, and
can_go_forward?
in TankMotionFSM
, to make a decision when to jump into navigating or stuck
state.
Those new states are nothing fancy:
13-advanced-ai/entities/components/ai/tank_navigating_state.rb
1
class
TankNavigatingState
<
TankMotionState
2
def
initialize
(
object
,
vision
)
3
@object
=
object
4
@vision
=
vision
5
end
6
7
def
update
8
change_direction
if
should_change_direction?
9
drive
10
end
11
12
def
change_direction
13
closest_free_path
=
@vision
.
closest_free_path
14
if
closest_free_path
15
@object
.
physics
.
change_direction
(
16
Utils
.
angle_between
(
17
@object
.
x
,
@object
.
y
,
*
closest_free_path
))
18
end
19
@changed_direction_at
=
Gosu
.
milliseconds
20
@will_keep_direction_for
=
turn_time
21
end
22
23
def
wait_time
24
rand
(
10
.
.
100
)
25
end
26
27
def
drive_time
28
rand
(
1000
.
.
2000
)
29
end
30
31
def
turn_time
32
rand
(
300
.
.
1000
)
33
end
34
end
TankNavigatingState
simply chooses a random free path, changes direction to it and keeps driving.
13-advanced-ai/entities/components/ai/tank_stuck_state.rb
1
class
TankNavigatingState
<
TankMotionState
2
def
initialize
(
object
,
vision
)
3
@object
=
object
4
@vision
=
vision
5
end
6
7
def
update
8
change_direction
if
should_change_direction?
9
drive
10
end
11
12
def
change_direction
13
closest_free_path
=
@vision
.
closest_free_path
14
if
closest_free_path
15
@object
.
physics
.
change_direction
(
16
Utils
.
angle_between
(
17
@object
.
x
,
@object
.
y
,
*
closest_free_path
))
18
end
19
@changed_direction_at
=
Gosu
.
milliseconds
20
@will_keep_direction_for
=
turn_time
21
end
22
23
def
wait_time
24
rand
(
10
.
.
100
)
25
end
26
27
def
drive_time
28
rand
(
1000
.
.
2000
)
29
end
30
31
def
turn_time
32
rand
(
300
.
.
1000
)
33
end
34
end
TankStuckState
is nearly the same, but it keeps driving away from @stuck_at
point, which is set
by TankMotionFSM
upon transition to this state.
class
TankMotionFSM
STATE_CHANGE_DELAY
=
500
LOCATION_CHECK_DELAY
=
5000
def
initialize
(
object
,
vision
,
gun
)
# ...
@stuck_state
=
TankStuckState
.
new
(
object
,
vision
,
gun
)
@navigating_state
=
TankNavigatingState
.
new
(
object
,
vision
)
set_state
(
@roaming_state
)
end
# ...
def
choose_state
unless
@vision
.
can_go_forward?
unless
@current_state
==
@stuck_state
set_state
(
@navigating_state
)
end
end
# Keep unstucking itself for a while
change_delay
=
STATE_CHANGE_DELAY
if
@current_state
==
@stuck_state
change_delay
*=
5
end
now
=
Gosu
.
milliseconds
return
unless
now
-
@last_state_change
>
change_delay
if
@last_location_update
.
nil?
@last_location_update
=
now
@last_location
=
@object
.
location
end
if
now
-
@last_location_update
>
LOCATION_CHECK_DELAY
puts
"checkin location"
unless
@last_location
.
nil?
||
@current_state
.
waiting?
if
Utils
.
distance_between
(
*
@last_location
,
*
@object
.
location
)
<
20
set_state
(
@stuck_state
)
@stuck_state
.
stuck_at
=
@object
.
location
return
end
end
@last_location_update
=
now
@last_location
=
@object
.
location
end
# ...
end
# ...
end
What this does is automatically change state to navigating when tank is about to hit an obstacle.
It also tracks tank location, and if tank hasn’t moved 20 pixels away from it’s original direction
for 5 seconds, it enters TankStuckState
, which deliberately tries to navigate away from the
stock_at
spot.
AI navigation has just got significantly better, and it didn’t take that many changes.
Wrapping It Up
Our journey into the world of game development has come to an end. We have learned enough to produced a playable game, yet only scratched the surface. Writing this book was a very enlightening experience, and hopefully reading it inspired or helped someone to get a start.
Lessons Learned
Building this small tanks game and learning about game development with Ruby certainly had some nasty bumps along the way, some of them made my head hit the ceiling.
Ruby Is Slow
This shouldn’t be a shocker, because Ruby is a dynamic, interpreted language, but how exactly slow
it is at some points was a staggering discovery. Probably the best evidence is that drawing map
tiles off screen using native extensions was actually faster than doing Camera#can_view?
checks
that involve simple integer arithmetic and range checks.
If your game is going to deal with large number of entities, Ruby will start letting you down. Dreaming about going pro? Go for C++, you won’t make a mistake here.
Knowing this, keep in mind that Ruby is a wonderful language, that has it’s own strengths. It’s great for prototyping and dynamic things. Some 5-10 lines of Ruby could translate into 50-100 lines of C++. Also, knowing multiple languages makes you a better developer.
Packaging Ruby Games Sucks
Unless you are releasing your game for tech savvy guys who can gem install
it, get ready to go
through hell. There is no nice and easy way to create a
standalone executable application from Ruby code that involves native extensions. And you will go
through hell once for every operating system you want to publish your game for.
That’s not everything. Want to use the latest Ruby version? Check if you can make a package for it in your target OS before you start coding. Thinking of using something that relies on ImageMagick? Too bad, you probably won’t be able to package the game into a native standalone app, at least on OSX. If you are planning on releasing the game, package early and package often, for every OS, and check if there will be no problems with native extensions.
Plan Networked Multiplayer Early
If you are going to build a game, don’t make a mistake of thinking “I’ll just make it multiplayer later”, start at the very beginning. This was a lesson I learned the hard way. There had to be a chapter in this book about turning Tanks into multiplayer, but it didn’t happen, because it would require a major rewrite of the code.
Creating A Well Polished Game Requires Extraordinary Effort
Hacking up a rough prototype is extremely fun. You get to build an engine, wire everything together. It definitely gives a sense of achievement. Turning it into a great game, however, is a different story. You can spend hours or even days tweaking how game controls work and still remain unsatisfied. Every tiny detail can be pushed further. Prefer quality over quantity, and remember that you probably cannot afford both and actually finish it within next couple of years.
Start Small, Take Baby Steps
Your first few games should be small experiments, prototypes or demos. Don’t attempt to build a game you wanted to build forever with your first shot. Try reimplementing Tetris, Pacman or Bejeweled instead. You will find it to be challenging enough, and when you will feel you have the skills to do something bigger, practice just a little more.
Don’t Reinvent The Wheel
Before doing anything, research. You will probably not get point in poly collision detection better than W. Randolph Franklin did it in his research. Even if you think you can do it on your own, learn what others discovered before you. Learn from other’s mistakes, not your own.
Special Thanks
I would like to thank Julian Raschke for creating and maintaining Gosu and for all the help on IRC, Gosu forums and GitHub. This book would not exist without your enormous contribution to Ruby game development scene.
Shout out goes to Shawn Anderson, creator of Gamebox. Thank you for moral support and encouragement. Studying Gamebox source code taught me many things about Gosu and game development.
You can find Julian, Shawn and more game development enthusiasts in #gosu on FreeNode.
And most importantly, thank you for reading this book!