markruff icon

Predator vs Prey graphical simulator (Ruby and GTK3)

markruff | PRO | 01/02/16 02:05:43 AM UTC | 0 ⭐ | 446 👁️ | Never ⏰ | []
Ruby |

15.61 KB

|

None

|

0 👍

/

0 👎

#!/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

Comments