Refactoring The Prototype - Developing Games With Ruby (2014)

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

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

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

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.