A little while back, I wrote in a review of a Ruby bowling sim that I might try my hand at modelling the game myself, focussing on the funky "deferred scores" system. Since a2bfay, who posted the original question, has since posted a thoroughly updated piece of code, I figured I'd better get mine finished, especially since I chose a very different approach. I just figured it'd be fun to put up for review. The main point for me was mostly to do some plain ol' Ruby for fun and zero profit. It's a self-imposed programming-challenge, I suppose.
This it not a complete bowling simulator like a2bfay's, though; it's actually just a single class for now. It does a lot, though. Perhaps too much, but that's for a review to tackle.
The class in question models the concept of a "frame" in a game of bowling. Since a frame's final score might depend on shots/rolls in the following frames, I implemented the frames as nodes in a singly-linked list. So at any time, a frame can report its score, and whether or not said score is final by examining itself and (if necessary) shots from its following frame(s). The final frame behaves differently, allowing up to 3 shots/rolls in order to resolve any non-final scores.
Basic requirements:
- Adherence to regular 10-pin bowling rules. No support for any of the variants.
- Ability to calculate scores "live", i.e. while the game is in progress.
- Ability to easily control "skill", i.e. how many pins to knock down.
I fairly happy with the result, though the class is pretty packed with functionality. For instance, here's how to play a perfect game:
game = Frame.create_list(10).each do |frame| # create 10 linked frames
frame.play { 10 } # bowl a strike in each frame
end
game.map(&:score).inject(&:+) # => 300
So it doesn't leave much for other classes to do1. Not necessarily a bad thing, of course.
Anyway, enough intro, here's the code. It's also in a gist along with many, many tests (RSpec). Kinda went overboard, I guess, but I wanted to stretch my testing muscles.
# This class models a "frame" in 10 pin bowling.
class Frame
# Number of pins lined up
PIN_COUNT = 10
# The next frame relative to the receiver
attr_reader :next_frame
# Create an array of +count+ linked frames, in which frame N is linked
# to frame N+1 from first to last.
def self.create_list(count)
raise RangeError.new("count must be greater than 0") if count.to_i < 1
frames = [self.new]
(count.to_i - 1).times.inject(frames) do |frames|
frames << frames.last.append_frame
end
end
def initialize #:nodoc:
@shots = []
end
# Returns a dup of the shots in this frame.
def shots
@shots.dup
end
# Append a new frame to the receiver (if one exists, it's returned).
# If the receiver has already recorded 1 or more shots, no new frame
# will be created, and the method will just return the existing
# +next_frame+ which may be +nil+.
def append_frame
return @next_frame if last? && !shots.empty?
@next_frame ||= self.class.new
end
# True if the receiver has been played to completion (i.e. no more shots
# are possible).
def played?
shots.count >= shot_limit
end
# Play this frame till it's done. Returns the shots that were recorded
#
# See also #play_one_shot
def play(&block)
shots_played = []
shots_played << play_one_shot(&block) until played?
shots_played
end
# Bowl a single ball. If the frame's been played, +nil+ will be
# returned.
#
# The block will receive the number of pins still standing, and
# the numbers of the shots already taken, and must return the number
# of pins to topple. The number is clamped to what's possible.
def play_one_shot(&block)
return if played?
shot = yield remaining_pins, shots.count
@shots << [0, shot.to_i, remaining_pins].sort[1]
shots.last
end
# True if the receiver has no following frames (see also #next_frame).
def last?
next_frame.nil?
end
# The score for this frame (may not be final).
def score
scored_shots = successive_shots[0, shots_required_for_score]
scored_shots.inject(0, &:+)
end
# True if have all the necessary shots been taken (in this frame or others).
def score_finalized?
successive_shots.count >= shots_required_for_score
end
# The number of pins knocked down in the shots that have been taken.
def pins
shots.inject(0, &:+)
end
# True if the first shot was a strike.
def strike?
shots.first == PIN_COUNT
end
# True if the first 2 shots constitute a spare.
def spare?
!strike? && shots[0, 2].inject(0, &:+) == PIN_COUNT
end
# Shots required in order to calculate the final score for this frame.
# This includes shots already taken this frame.
def shots_required_for_score
strike? || spare? ? 3 : 2
end
# Returns the number of pins currently standing.
def remaining_pins
remaining = PIN_COUNT - (pins % PIN_COUNT)
remaining %= PIN_COUNT if played?
remaining
end
# Collect shots from this frame and successive frames.
def successive_shots
return [] if @shots.empty?
collected = @shots.dup
collected += next_frame.successive_shots if next_frame
collected
end
private
# The number of shots that can be taken in this frame.
def shot_limit
return 3 if fill_ball?
return 1 if strike?
return 2 # intentional but unnecessary return; just there for formatting
end
# Does this frame have a 3rd shot?
def fill_ball?
last? && (strike? || spare?)
end
end
Things I've noticed myself:
Conflating a "regular" mid-game frame and the last frame in a single class. They do differ a fair bit. But while I tried breaking it into separate classes, super and sub classes, and extracting logic into modules, I didn't find a structure I liked. So it's all one class.
There are a handful of "magic numbers" in there, but creating constants or methods for them seemed more trouble than it's worth, since I'm aiming exclusively at regular 10-pin bowling. Still, though...
Again, the class just does a lot. Hmm... it is kinda complex, isn't it?
Edits & addenda:
Edit: Added a missing comment on Frame.create_list
1) I should have been clearer. I do not intend for this single class to do and be everything, even if it does do a lot. To more fully model a game, you'd still need something like a Game
class, perhaps a Player
class, etc..
.play
and on toplay_one_shot
is where you'd control for skill, yes? (And.create_list
is a class method, so you don't have to make some other class directly responsible for generating instances - got it.) \$\endgroup\$create_list
is a factory method. Typically, it's fine to just let others create instances of your class, but in this case, frame instances are very much intended to be linked to each other to form a list. It makes sense to me that the logic for doing so is present inFrame
(just for ease of use if nothing else). \$\endgroup\$