Developing Games With Ruby (2014)
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:
1. Loads all ground unit sprites. If some other class handled it, we could reuse the code to load other units.
2. Handles sound effects.
3. Uses Gosu::Song for moving sounds. That limits only one tank movement sound per whole game. Basically, we abused Gosu here.
4. 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.
5. Draws graphics on screen.
6. Calculates physical properties, like speed, acceleration.
7. Detects movement collisions.
Bullet is not perfect either:
1. It renders it’s graphics.
2. It handles it’s movement trajectories and other physics.
3. It treats Explosion as part of it’s own lifecycle.
4. Draws graphics on screen.
5. Handles sound effects.
Even the relatively small Explosion class is too monolithic:
1. It loads it’s graphics.
2. It handles rendering, animation and frame skipping
3. 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:
Decoupled Tank
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.
Game objects and their components
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.
Object Pool
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.