Implementing Game Statistics - Developing Games With Ruby (2014)

Developing Games With Ruby (2014)

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.

Pausing the game to see the score

Pausing the game to see the score

For now, scoring most kills is relatively simple. This should change when we will tell enemy AI to collect powerups when appropriate.