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