#!/usr/bin/ruby # # Graphical simulation of a group of predators and a school of prey # # Author: Mark Ruff # # PREDATORS: die of old age, chase down prey within their visual range and eat # them, will reproduce when they have eaten enough prey (at the moment this # is a random spawn of a new predator) # # PREY: run from predators, try not to crash into very close prey, try to # follow prey close to them (same heading), and if not close enough will move # towards other prey. reproduction time based (random spawn) # # Configuration settings in predator-prey.config as follows: # (To do: allow comments in the config file) # # window size # x_max 300 # y_max 300 # # number of prey and predators # school_size 20 # predator_size 5 # # individual settings for prey # rep 10 # fol 20 # att 40 # fear 40 # rep_mag 0.2 # fol_mag 0.4 # att_mag 0.6 # fear_mag 0.95 # # settings for the school # s_reprod_time 15 # s_reprod_rate 0.1 # s_speed 1.5 # # settings for the predator # p_speed 2.0 # p_reprod_time 5 # p_vision 40 # p_magnitude 0.2 # p_killzone 7.0 # p_oldage 150 # # Installing Ruby/GTK3 on Fedora 23 (see: http://pastebin.com/0eWKAwTH) # sudo dnf install ruby-devel # sudo dnf install gtk3-devel # sudo dnf install redhat-rpm-config # gem install gtk3 # require "gtk3" # 2D catesian Point class Point attr_accessor :x, :y def initialize(x, y) @x = x @y = y end # actual distance between this and another point def distance(other) return Math.sqrt( (@x-other.x)**2 + (@y-other.y)**2 ) end # squared Euclidean distance (when ranking only, avoid the costly sqrt) def distance_squared(other) return (@x-other.x)**2 + (@y-other.y)**2 end # angle formed by line between two points and the x axis (heading) def heading_to(other) return Math.atan2( (other.y - @y), (other.x - @x) ) end # a point a certain distance and heading from the current point def destination(heading, distance) return Point.new( @x + distance*Math.cos(heading), @y + distance*Math.sin(heading)) end end # Base class for Creatures in the simulator # Has a position, direction it is heading, speed and "picutre" # In the current iteration of the program the picture is just a colour class Creature attr_accessor :position, :heading, :speed, :pic def initialize(x, y, heading, speed, pic) @position = Point.new(x,y) @heading = heading @speed = speed @pic = pic end # Have the creature deviate away from another point # This is scaled by the "strength", a factor from 0 - 1 def deviate_from(p,strength) diff = (@heading - @position.heading_to( p )) % (Math::PI*2) if diff < Math::PI then @heading += (Math::PI - diff)*strength elsif diff > Math::PI then @heading -= (diff - Math::PI)*strength end end # Have the creature deviate towards another point # This is scaled by the "strength", a factor from 0 -1 def deviate_to(p,strength) diff = (@position.heading_to( p ) - @heading) % (Math::PI*2) if diff < Math::PI @heading += diff*strength else @heading -= (Math::PI*2 - diff)*strength end end # Actually move to a new point based on our current location (point), # our heading and our speed plus a small amount of randomness. # Movement will need to be constrained by the window in which we exist # (0-x_max and 0-y_max). def finalise_move(x_max, y_max) # randomness @heading += 0.1 * (rand * 2 - 1) # new position @position.x = @position.x + @speed*Math.cos(@heading) @position.y = @position.y + @speed*Math.sin(@heading) # to do: move this poorly placed wrap variable into the settings file! wrap = true if wrap then # if we have moved out of bounds, bounce back in if @position.x < 0 then @position.x = @position.x.abs @heading = Math::PI - @heading elsif @position.x > x_max - 1 then @position.x = 2 * x_max - @position.x - 1 @heading = Math::PI - @heading end if @position.y < 0 then @position.y = @position.y.abs @heading = Math::PI*2 - @heading elsif @position.y > y_max - 1 then @position.y = 2*y_max - @position.y - 1 @heading = Math::PI*2 - @heading end else # if not wrapping, then pop in on the other side of the window if @position.x < 0 then @position.x = x_max -1 elsif @position.x > x_max - 1 then @position.x = 0 end if @position.y < 0 then @position.y = y_max - 1 elsif @position.y > y_max - 1 then @position.y = 0 end end end end # Simulated predator, is a Creature # Will hunt down Prey: # - Out of the Prey that are close enough (distance away < vision), find the # closest and deviate towards this Prey (by a certain magnitude) # - If this movement puts the Predator within a certain distance (kill_zone) # the Prey is "killed". # Will eventually die of old age (has a current age and death_age) class Predator < Creature attr_accessor :vision, :magnitude, :kill_zone, :age, :death_age def initialize(x, y, heading, speed, vision=80, mag=0.4, killzone = 3.0, death_age = 150, pic = [1, 0.1, 0.1]) super x, y , heading, speed, pic @vision = vision @magnitude = mag @kill_zone = killzone @age = 0 @death_age = death_age end def move(food,x_max,y_max) @age += 1 if @age > @death_age then # kill me return false end closest = nil # point closest_distance = 100000 # arbitrary big number food.each do |f| distance = @position.distance(f.position) if distance < vision then if distance < closest_distance then closest_distance = distance closest = f.position end end end if closest != nil then deviate_to(closest,@magnitude) end finalise_move(x_max,y_max) end end # Prey - these act like a school of fish: # - Die if within the kill_zone of a Predator # - Deviate away from other Prey that are very close (dist <= repulsion) # - Adjust their heading towards that of Prey that are moderately close # (distance < following) # - Deviate towards other Prey that are not too far (distance <= attraction) # - Deviate away from Predators if they are too close (dist <= fear), and in # this case ignore the other Prey... just run # All of the adjustments are based on individual scaling variables class Prey < Creature attr_accessor :repulsion, :following, :attraction, :fear, :rep_mag, :fol_mag, :att_mag, :fear_mag def initialize(x, y, heading, speed, pic = [0.5,0.5,0.5], rep = 10, fol = 20, att = 40, fear = 40, rep_mag = 0.4, fol_mag = 0.4, att_mag = 0.4, fear_mag = 0.5) super x, y, heading, speed, pic @repulsion = rep @following = fol @attraction = att @fear = fear @rep_mag = rep_mag @fol_mag = fol_mag @att_mag = att_mag @fear_mag = fear_mag end # Move the Prey based on factors outlined above, returns true if still # alive after the move, false if dead def move(x_max, y_max, others, predators) afraid = false # First we handle reaction to any predators predators.each do |p| dist = @position.distance_squared(p.position) # if a predator is within kill range, return false and delete me if dist < p.kill_zone**2 then #delete me others.delete self return false end # if a predator is within my fear range, head away from it if dist < @fear**2 then afraid = true deviate_from(p.position,@fear_mag/Math::sqrt(dist)) end end if afraid then finalise_move(x_max,y_max) return true end # Now move in relation to the other prey. # Make an array of prey that lie within each of our ranges (repulsion, # follow, attraction). We can ignore any prey outside of the attraction # range @rep_prey = [] @fol_prey = [] @att_prey = [] # using this cutoff as an optimisation WILL BREAK any attempts to # combine all 3 factors (rep, fol, att), so at the moment we only use # one factor (based on the closest prey) cutoff = @attraction # for each prey, except those before me in the array ... # this avoids checking A -> B and then also B -> A others[others.find_index(self)+1,others.length].each do |o| # optimise by looking in a SQUARE around me, rather than a circle... # don't need to calculate the actual distance if delta x or y out of range if (@position.x - o.position.x).abs < cutoff && (@position.y - o.position.y).abs < cutoff then # optimise by checking the squared distance against the various # cut-off settings squared (square root do get distance = more costly) dist = @position.distance_squared(o.position) if dist < @repulsion**2 then @rep_prey.push o cutoff = @repulsion elsif dist < @following**2 then @fol_prey.push o cutoff = @following elsif dist < @attraction**2 then @att_prey.push o end end end # if other prey are too close, move away if !@rep_prey.empty? then x_add = 0 y_add = 0 @rep_prey.each do |o| x_add += o.position.x y_add += o.position.y end x_add = x_add / @rep_prey.length.to_f y_add = y_add / @rep_prey.length.to_f deviate_from(Point.new(x_add, y_add),rep_mag) # OTHERWISE, try to align our heading with prey moderately close elsif !@fol_prey.empty? then vectoring = position @fol_prey.each do |o| vectoring = vectoring.destination(o.heading,1) end deviate_to(vectoring,fol_mag) # OTHERWISE, try to get closer to prey within the "attraction" distance elsif !@att_prey.empty? then x_add = 0 y_add = 0 @att_prey.each do |o| x_add += o.position.x y_add += o.position.y end x_add = x_add / @att_prey.length.to_f y_add = y_add / @att_prey.length.to_f deviate_to(Point.new(x_add, y_add),att_mag) end finalise_move(x_max,y_max) end end # Our Simulation is a Gtk:Window, so it can be graphically displayed # Read in various settings from a class Simulation < Gtk::Window attr_accessor :predator, :school, :school_size, :x_max, :y_max, :kills, :rep, :fol, :att, :fear, :rep_mag, :fol_mag, :att_mag, :fear_mag, :reproduced def initialize(settings) # Window settings, including initialisation of the kill counter @x_max = settings["x_max"].to_i @y_max = settings["y_max"].to_i @counter = 0 # a school is an array of prey # reproduction rates and speed are kept here at a simulator level @school = [] @school_size = settings["school_size"].to_i @s_reprod_time = settings["s_reprod_time"].to_i @s_reprod_rate = settings["s_reprod_rate"].to_f @s_speed = settings["s_speed"].to_f # settings for the individual prey (passed to initializer of Prey class) @rep = settings["rep"].to_i @fol = settings["fol"].to_i @att = settings["att"].to_i @fear = settings["fear"].to_i @rep_mag = settings["rep_mag"].to_f @fol_mag = settings["fol_mag"].to_f @att_mag = settings["att_mag"].to_f @fear_mag = settings["fear_mag"].to_f # settings for the predator @p_speed = settings["p_speed"].to_f @p_vision = settings["p_vision"].to_i @p_magnitude = settings["p_magnitude"].to_f @p_killzone = settings["p_killzone"].to_f @p_oldage = settings["p_oldage"].to_i # settings for the group of predators as a whole (size - starting quantity) @predator_size = settings["predator_size"].to_i @p_reprod_time = settings["p_reprod_time"].to_i @reproduced = false # make our school of prey (up to "school_size", set in settings file) 0.upto(@school_size - 1) do |x| @school.push Prey.new(rand(0..@x_max-1), rand(0..@y_max - 1), rand*Math::PI*2, @s_speed, [1,1, rand/2+0.5], @rep, @fol, @att, @fear, @rep_mag, @fol_mag, @att_mag, @fear_mag) end # similarly, make our predators @predator = [] @predator_size.times do @predator.push Predator.new(rand(0..@x_max-1), rand(0..@y_max -1), rand*Math::PI*2, @p_speed, @p_vision, @p_magnitude, @p_killzone, @p_oldage) end @kills = 0 # set our Gtk:Window up (including call to super() to initialize) super() set_title "Simulator" set_window_position :center signal_connect "destroy" do Gtk.main_quit end @darea = Gtk::DrawingArea.new @darea.set_size_request(@x_max, @y_max) @vbox = Gtk::Box.new :vertical @hbox = Gtk::Box.new :horizontal @label = Gtk::Label.new "Kill Counter: " @kill_counter = Gtk::Label.new "0" @darea.signal_connect "draw" do on_draw end add @vbox @vbox.pack_start @darea @vbox.pack_start @hbox @hbox.pack_start @label @hbox.pack_start @kill_counter show_all end # drawing method for our window def on_draw cr = @darea.window.create_cairo_context cr.set_source_rgb 0, 0, 0 cr.set_line_width 4 cr.set_line_cap "round" cr.paint # draw each predator @predator.each do |p| cr.set_source_rgb p.pic[0], p.pic[1], p.pic[2] cr.move_to(p.position.x, p.position.y) d = p.position.destination(p.heading,6) # why 7 - should make const cr.line_to(d.x, d.y) cr.stroke end cr.set_line_width 4 # draw each prey school.each do |s| cr.set_source_rgb s.pic[0], s.pic[1], s.pic[2] cr.move_to(s.position.x, s.position.y) d = s.position.destination(s.heading,6) cr.line_to(d.x, d.y) cr.stroke end # draw the kill counter @kill_counter.set_label @kills.to_s end # method to move forward a step in time in the simulator def sim_step @counter += 1 # move each predator, if they have died of old age, delete them @predator.each do |p| if p.move(school, @x_max, @y_max) == false then @predator.delete(p) end end # move each prey in the school, if they have been eaten, update kill counter self.school.each do |s| if s.move(@x_max, @y_max,@school,@predator) == false then @kills += 1 end end # redraw the window cr = @darea.window.create_cairo_context draw cr # REPRODUCTION # PREY: Add new prey based on the reproduction_time. The amount added # Is based on the current population level and the reproduction rate # Keep reproducing so long as there are some left if @counter % @s_reprod_time == 0 then (@school.length * @s_reprod_rate).ceil.times do @school.push Prey.new(rand(0..@x_max-1), rand(0..@y_max - 1), rand*Math::PI*2, @s_speed, [1,1, rand/2+0.5], @rep, @fol, @att, @fear, @rep_mag, @fol_mag, @att_mag, @fear_mag) end end # PREDATORS: reprouction time not based on number of steps through the # simulator, but rather number of kills. i.e will die out if no prey if (@kills+1) % @p_reprod_time == 0 then if !reproduced then @predator.push Predator.new(rand(0..@x_max-1), rand(0..@y_max -1), rand*Math::PI*2, @p_speed, @p_vision, @p_magnitude, @p_killzone, @p_oldage) @reproduced = true end else @reproduced = false end end end # Here we set up and run our Simulation # Pull in all the settings from our configuration file settings_raw = File.readlines("predator-prey.config") settings = {} settings_raw.each do |s| values = s.split(/\s+/) settings[values[0]] = values[1] end puts settings # Create a new simulation using these settings s = Simulation.new(settings) # Step through the simulation step by step (forever) # To Do: Add a button to close in a clean fashion GLib::Timeout.add 50 do s.sim_step true end Gtk.main