From: Ruby Quiz Date: 2006-06-15T22:47:37+09:00 Subject: [SUMMARY] Running Coach (#82) This quiz turns out to be a little bit of work, if you want to get some decent feedback to the user. Adam Shelly hammered out a reasonably complete solution though, so let's have a look at it: $CheerThreshold = 6 #decrease to get more random encouragement $LongThreshold = 120 #minimum time to be considered a "long" run class Phase attr_reader :action, :seconds def initialize action, time @action = action.downcase @seconds = time.to_i end end # ... We can see some setup work here for variables that allow users to tweak the output. We also have the trivial Phase class definition, which is just a data class for linking actions and times. Here's the main event loop: class Coach def initialize filename File.open(filename) {|f| @rawdata = f.read.split("\n") } @duration = 0 @runs = @longs = @walks = 0 @encouragometer = 0 @step = [30,15,10,5,5] end def coach build_timeline say summarize(2) say start_prompt @time = Time.now @target_time = @time while (phase = @phases.shift) update_summary phase narrate_phase phase if @phases.size > 0 say transition(@phases[0].action) say summarize(rand(2)) end end say finish_line end # ... There's nothing too interesting about initialize() which is just assigning defaults to the instance variables. Have a look at the coach() method though. This is the process the application runs through, and I really like how well it reads. It builds up the timeline of events, hits user with a summary and starting prompt, then launches into Phase processing. Each Phase is narrated to the user, and then the code transitions naturally to the next Phase. Finally the code sends the finish line message to indicate a successful workout. Let's see what narrating a phase involves: # ... def narrate_phase phase say what_to_do_for(phase) @target_time += phase.seconds delta = (@target_time - Time.now).to_i stepidx = 0 while (delta > 0) stepidx+=1 if delta < @step[stepidx]+1 wait_time = delta % @step[stepidx] wait_time += @step[stepidx] if wait_time <= 0 wait(wait_time) delta = (@target_time - Time.now).to_i encourage_maybe say whats_left(phase.action,delta) if delta > 0 end end # ... Obviously, this method is mostly about time management. It breaks a Phase down into smaller chunks, so that it can provide encouragement frequently and inform the user of what is left to be done. Note the clever output messages here again that read so naturally: what_to_do_for(), encourage_maybe(), and whats_left(). # ... def update_summary phase @duration -= phase.seconds @runs -= 1 if phase.action == 'run' @longs -= 1 if phase.action == 'run' and phase.seconds >= $LongThreshold @walks -= 1 if phase.action == 'walk' end def build_timeline @phases = @rawdata.map {|command| p = Phase.new(*command.split) @duration += p.seconds @runs += 1 if p.action == 'run' @longs += 1 if p.action == 'run' and p.seconds >= $LongThreshold @walks += 1 if p.action == 'walk' p } end # ... These two methods are quite similar save that one adds and the other subtracts. First, build_timeline() constructs the Phase objects from the import file. As it goes through, it counts things like the total number of walks and runs a person needs to complete. Then, update_summary() runs inside each Phase of the event loop ticking off the walks and runs the user has completed. Here's the say() method that would eventually need to be replaced with speech programming: # ... def say s puts s #todo: replace with speech end # ... Now, take a look at this: # ... def wait n if $DEBUG puts "...waiting #{n} seconds..." @target_time -= n else $stdout.flush sleep(n) end end # ... This is obviously the delay method and it mainly just calls sleep(). However, I like how it can be set to just explain what the pause would have been, in $DEBUG mode. That makes testing the application much more pleasant. Two more helper methods: # ... def encourage_maybe @encouragometer += rand(3) if (@encouragometer > $CheerThreshold) say cheer @encouragometer = 0 end end def timesay secs secs = secs.to_i s = "" if secs > 60 min = secs/60 secs -= min*60 s += "#{min} minute" s += 's' if min > 1 s += ' and ' if secs > 0 end if secs > 0 s += "#{secs} second" s += 's' if secs > 1 end s end # ... There's the definition for the encourage_maybe() call I pointed out earlier. It just randomly decides if a cheer should be emitted. The other method, timesay(), is a helper like we are use to in Rails. It just humanizes the output of some number of seconds by breaking it into minutes and seconds. Next the code has several output methods, of which I'll just show a couple: # ... # All the phrases should be below this line, not mixed up in the logic def what_to_do_for phase s = "#{phase.action} for #{timesay(phase.seconds)} \n" s += "You are almost done" if @phases.size == 1 s end def whats_left act, time timestr = timesay(time) s = [ "You have #{timestr} more to #{act}", "#{act} for #{timestr} more", "only #{timestr} left of #{act}ing", "You have #{timestr} more to #{act}", "#{timestr} left in this phase", "There are #{timestr} until the next activity" ] s[rand(s.size)] end # ... several more output routines not shown ... end # ... You can see that these methods just use simple conditional logic or random picks to vary the program's output. With several of these methods, the end result is a fairly good mix of prompts for the user. Here's the last line that turns it into a solution: # ... Coach.new(ARGV[0]||"week3.txt").coach My thanks to those who stole the time from their busy running schedules to code up a solution. These scripts should have us all in shape by RubyConf! Tomorrow, we will try an extremely common computerism, but see if we can handle it a little better than the usual treatment...