# Copyright 2007-2008 Jay McGavren, jay@mcgavren.com. # # This file is part of Zyps. # # Zyps is free software; you can redistribute it and/or modify # it under the terms of the GNU Lesser General Public License as published by # the Free Software Foundation; either version 3 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU Lesser General Public License # along with this program. If not, see . require 'observer' module Zyps #A virtual environment. class Environment include Observable #An array of GameObject objects that reside in the Environment. attr_accessor :objects #An array of EnvironmentalFactor objects that act on any GameObject in the Environment. attr_accessor :environmental_factors #Takes a hash with these keys and defaults: # :objects => [], # :environmental_factors => [] def initialize (options = {}) options = { :objects => [], :environmental_factors => [] }.merge(options) self.objects, self.environmental_factors = options[:objects], options[:environmental_factors] @clock = Clock.new end #Make a deep copy. def copy copy = self.clone #Currently, we overwrite everything anyway, but we may add some clonable attributes later. #Make a deep copy of all objects. copy.objects = [] @objects.each {|object| copy.objects << object.copy} #Make a deep copy of all environmental_factors. copy.environmental_factors = [] @environmental_factors.each {|environmental_factor| copy.environmental_factors << environmental_factor.copy} copy end #Allow everything in the environment to interact with each other. #Objects are first moved according to their preexisting vectors and the amount of time since the last call. #Then, each GameObject with an act() method is allowed to act on the environment. #Finally, each EnvironmentalFactor is allowed to act on the Environment. def interact #Get time since last interaction. elapsed_time = @clock.elapsed_time objects.each do |object| #Move each object according to its vector. begin object.move(elapsed_time) #Remove misbehaving objects. rescue Exception => exception puts exception, exception.backtrace objects.delete(object) next end #Have all creatures interact with the environment. if object.respond_to?(:act) begin #Have creature act on all GameObjects other than itself. object.act(objects.reject{|target| target.equal?(object)}) #Remove misbehaving objects. rescue Exception => exception puts exception, exception.backtrace objects.delete(object) next end end end #Have all environmental factors interact with environment. environmental_factors.each do |factor| begin factor.act(self) #Remove misbehaving environmental factors. rescue Exception => exception environmental_factors.delete(factor) puts exception, exception.backtrace next end end #Mark environment as changed. changed #Alert observers. notify_observers(self) end end #An object in the virtual environment. class GameObject #A universal identifier for the object. #Needed for DRb transmission, etc. attr_reader :identifier #The object's Location in space. attr_accessor :location #A Color that will be used to draw the object. attr_accessor :color #Radius of the object. attr_accessor :size #A Vector with the object's current speed and direction of travel. attr_accessor :vector #A String with the object's name. attr_accessor :name #An array of Strings with tags that determine how the object will be treated by Creature and EnvironmentalFactor objects in its environment. attr_accessor :tags #Takes a hash with these keys and defaults: # :name => nil, # :location => Location.new, # :color => Color.new, # :vector => Vector.new, # :age => 0, # :size => 1, # :tags => [] def initialize (options = {}) options = { :name => nil, :location => Location.new, :color => Color.new, :vector => Vector.new, :age => 0, :size => 1, :tags => [] }.merge(options) self.name, self.location, self.color, self.vector, self.age, self.size, self.tags = options[:name], options[:location], options[:color], options[:vector], options[:age], options[:size], options[:tags] @identifier = generate_identifier end #Make a deep copy. def copy copy = self.clone copy.vector = @vector.copy copy.color = @color.copy copy.location = @location.copy copy.tags = @tags.clone copy.identifier = generate_identifier copy.name = @name ? "Copy of " + @name : nil copy end #Size must be positive. def size=(v); v = 0 if v < 0; @size = v; end #Move according to vector over the given number of seconds. def move (elapsed_time) @location.x += @vector.x * elapsed_time @location.y += @vector.y * elapsed_time end #Time since the object was created, in seconds. def age; Time.new.to_f - @birth_time; end def age=(age); @birth_time = Time.new.to_f - age; end #Set identifier. #Not part of API; copy() needs this to make copy's ID unique. def identifier=(value) #:nodoc: @identifier = value end private #Make a unique GameObject identifier. def generate_identifier rand(99999999) #TODO: Current setup won't necessarily be unique. end end #A Creature is a GameObject that can sense and respond to other GameObjects (including other Creature objects). class Creature < GameObject #A list of Behavior objects that determine the creature's response to its environment. attr_accessor :behaviors #Identical to the GameObject constructor, except that it also takes a list of Behavior objects. #Takes a hash with these keys and defaults: # :name => nil # :location => Location.new # :color => Color.new # :vector => Vector.new # :age => 0 # :size => 1 # :tags => [] # :behaviors => [] def initialize (options = {}) options = { :behaviors => [] }.merge(options) super self.behaviors = options[:behaviors] end #Make a deep copy. def copy copy = super #Make deep copy of each behavior. copy.behaviors = [] @behaviors.each {|behavior| copy.behaviors << behavior.copy} copy end #Performs all assigned behaviors on the targets. def act(targets) behaviors.each {|behavior| behavior.perform(self, targets)} end end #Something in the environment that acts on creatures. #EnvironmentalFactors must implement an act(target) instance method. class EnvironmentalFactor end #An action that one Creature takes on another. class Action #Whether the action was previously started. attr_reader :started def initialize @started = false end #Make a deep copy. def copy; self.clone; end #Start the action. #Overriding subclasses must either call "super" or set the @started attribute to true. def start(actor, target) @started = true end def do(actor, target) raise NotImplementedError.new("Action subclasses must implement a do(actor, target) instance method.") end #Stop the action. #Overriding subclasses must either call "super" or set the @started attribute to false. def stop(actor, target) @started = false end #Synonym for started def started?; started; end end #A condition for one Creature to act on another. class Condition #Make a deep copy. def copy; self.clone; end def select(actor, targets) raise NotImplementedError.new("Condition subclasses must implement a select(actor, target) instance method.") end end #A behavior that a Creature engages in. #The target can have its tags or colors changed, it can be "herded", it can be destroyed, or any other action the library user can dream up. #Likewise, the subject can change its own attributes, it can approach or flee from the target, it can spawn new Creatures or GameObjects (like bullets), or anything else. class Behavior #An array of Condition subclasses. #Condition#select(actor, targets) will be called on each. attr_accessor :conditions #An array of Action subclasses. #Action#start(actor, targets) and action.do(actor, targets) will be called on each when all conditions are true. #Action#stop(actor, targets) will be called when any condition is false. attr_accessor :actions #Number of updates before behavior is allowed to select a new group of targets to act on. attr_accessor :condition_frequency #Will be used to distribute condition processing time between all Behaviors with the same condition_frequency. @@condition_order = Hash.new {|h, k| h[k] = 0} #Takes a hash with these keys and defaults: # :actions => [] # :conditions => [] # :condition_frequency => 1 def initialize (options = {}) options = { :actions => [], :conditions => [], :condition_frequency => 1 }.merge(options) self.actions = options[:actions] self.conditions = options[:conditions] self.condition_frequency = options[:condition_frequency] #Tracks number of calls to perform() so conditions can be evaluated with appropriate frequency. @condition_evaluation_count = 0 #Targets currently selected to act upon. @current_targets = [] end def condition_frequency= (value) #Condition frequency must be 1 or more. @condition_frequency = (value >= 1 ? value : 1) #This will be used to distribute condition evaluation time among all behaviors with this frequency. @condition_order = @@condition_order[@condition_frequency] @@condition_order[@condition_frequency] += 1 end #Make a deep copy. def copy copy = self.clone #Currently, we overwrite everything anyway, but we may add some clonable attributes later. #Make a deep copy of all actions. copy.actions = [] @actions.each {|action| copy.actions << action.copy} #Make a deep copy of all conditions. copy.conditions = [] @conditions.each {|condition| copy.conditions << condition.copy} copy end #Finds targets that meet all conditions, then acts on them. #Calls select(actor, targets) on each Condition, each time discarding targets that fail. #Then on each Action, calls Action#start(actor, targets) (if not already started) followed by Action#do(actor, targets). #If no matching targets are found, calls Action#stop(actor, targets) on each Action. #If there are no conditions, actions will occur regardless of targets. def perform(actor, targets) if condition_evaluation_turn? @current_targets = targets.clone conditions.each {|condition| @current_targets = condition.select(actor, @current_targets)} end actions.each do |action| if @current_targets.empty? and ! @conditions.empty? action.stop(actor, targets) #Not @current_targets; that array is empty. else action.start(actor, @current_targets) unless action.started? action.do(actor, @current_targets) end end end private #Return true if it's our turn to choose targets, false otherwise. def condition_evaluation_turn? #Every condition_frequency turns (plus our turn order within the group), return true. our_turn = ((@condition_evaluation_count + @condition_order) % @condition_frequency == 0) ? true : false #Track number of calls to perform() for staggering condition evaluation. @condition_evaluation_count += 1 our_turn end end #An object's color. Has red, green, and blue components, each ranging from 0 to 1. #* Red: Color.new(1, 0, 0) #* Green: Color.new(0, 1, 0) #* Blue: Color.new(0, 0, 1) #* White: Color.new(1, 1, 1) #* Black: Color.new(0, 0, 0) class Color include Comparable #Components which range from 0 to 1, which combine to form the Color. attr_accessor :red, :green, :blue def initialize (red = 1, green = 1, blue = 1) self.red, self.green, self.blue = red, green, blue end #Make a deep copy. def copy; self.clone; end #Automatically constrains value to the range 0 - 1. def red=(v); v = 0 if v < 0; v = 1 if v > 1; @red = v; end #Automatically constrains value to the range 0 - 1. def green=(v); v = 0 if v < 0; v = 1 if v > 1; @green = v; end #Automatically constrains value to the range 0 - 1. def blue=(v); v = 0 if v < 0; v = 1 if v > 1; @blue = v; end #Compares this Color with another to see which is brighter. #The sum of all components (red + green + blue) for each color determines which is greater. def <=>(other) @red + @green + @blue <=> other.red + other.green + other.blue end #Averages each component of this Color with the corresponding component of color2, returning a new Color. def +(color2) Color.new( (self.red + color2.red) / 2.0, (self.green + color2.green) / 2.0, (self.blue + color2.blue) / 2.0 ) end end #An object's location, with x and y coordinates. class Location #Coordinates can be negative, and don't have to be integers. attr_accessor :x, :y def initialize (x = 0, y = 0) self.x, self.y = x, y end #Make a deep copy. def copy; self.clone; end end #An object or force's velocity. #Has speed and angle components. class Vector #The length of the Vector. attr_accessor :speed def initialize (speed = 0, pitch = 0) self.speed = speed self.pitch = pitch end #Make a deep copy. def copy; self.clone; end #The angle along the X/Y axes. def pitch; Utility.to_degrees(@pitch); end def pitch=(degrees) #Constrain degrees to 0 to 360. value = degrees % 360 #Store as radians internally. @pitch = Utility.to_radians(value) end #The X component. def x; @speed.to_f * Math.cos(@pitch); end def x=(value) @speed, @pitch = Math.sqrt(value ** 2 + y ** 2), Math.atan(y / value) end #The Y component. def y; @speed.to_f * Math.sin(@pitch); end def y=(value) @speed, @pitch = Math.sqrt(x ** 2 + value ** 2), Math.atan(value / x) end #Add this Vector to vector2, returning a new Vector. #This operation is useful when calculating the effect of wind or thrust on an object's current heading. def +(vector2) #Get the x and y components of the new vector. new_x = (self.x + vector2.x) new_y = (self.y + vector2.y) new_length_squared = new_x ** 2 + new_y ** 2 new_length = (new_length_squared == 0 ? 0 : Math.sqrt(new_length_squared)) new_angle = (new_x == 0 ? 0 : Utility.to_degrees(Math.atan2(new_y, new_x))) #Calculate speed and angle of new vector with components. Vector.new(new_length, new_angle) end end #A clock to use for timing actions. class Clock def initialize reset_elapsed_time end #Make a deep copy. def copy; self.clone; end #Returns the time in (fractional) seconds since this method was last called (or on the first call, time since the Clock was created). def elapsed_time time = Time.new.to_f elapsed_time = time - @last_check_time @last_check_time = time elapsed_time end def reset_elapsed_time @last_check_time = Time.new.to_f end end #Various methods for working with Vectors, etc. module Utility PI2 = Math::PI * 2.0 #:nodoc: #Empty cached return values. def Utility.clear_caches @@angles = Hash.new {|h, k| h[k] = {}} @@distances = Hash.new {|h, k| h[k] = {}} end #Initialize caches for return values. Utility.clear_caches #Turn caching of return values on or off. @@caching_enabled = false def Utility.caching_enabled= (value) @@caching_enabled = value Utility.clear_caches if ! @@caching_enabled end #Get the angle (in degrees) from one Location to another. def Utility.find_angle(origin, target) if @@caching_enabled #Return cached angle if there is one. return @@angles[origin][target] if @@angles[origin][target] return @@angles[target][origin] if @@angles[target][origin] end #Get vector from origin to target. x_difference = target.x - origin.x y_difference = target.y - origin.y #Get vector's angle. radians = Math.atan2(y_difference, x_difference) #Result will range from negative Pi to Pi, so correct it. radians += PI2 if radians < 0 #Convert to degrees. angle = to_degrees(radians) #Cache angle if caching enabled. if @@caching_enabled @@angles[origin][target] = angle #angle + 180 = angle from target to origin. @@angles[target][origin] = (angle + 180 % 360) end #Return result. angle end #Get the distance from one Location to another. def Utility.find_distance(origin, target) if @@caching_enabled #Return cached distance if there is one. return @@distances[origin][target] if @@distances[origin][target] end #Get vector from origin to target. x_difference = origin.x - target.x y_difference = origin.y - target.y #Get distance. distance = Math.sqrt(x_difference ** 2 + y_difference ** 2) #Cache distance if caching enabled. if @@caching_enabled #Origin to target distance = target to origin distance. #Cache such that either will be found. @@distances[origin][target] = distance @@distances[target][origin] = distance end #Return result. distance end #Convert radians to degrees. def Utility.to_degrees(radians) radians / PI2 * 360 end #Convert degrees to radians. def Utility.to_radians(degrees) radians = degrees / 360.0 * PI2 radians = radians % PI2 radians += PI2 if radians < 0 radians end #Reduce a number to within an allowed maximum (or minimum, if the number is negative). def Utility.constrain_value(value, absolute_maximum) if (value.abs > absolute_maximum) then if value >= 0 then value = absolute_maximum else value = absolute_maximum * -1 end end value end #Given a normal and an angle, find the reflection angle. def Utility.find_reflection_angle(normal, angle) incidence_angle = normal - angle reflection_angle = normal + incidence_angle reflection_angle %= 360 reflection_angle end #Given two GameObjects, determine if the boundary of one crosses the boundary of the other. def Utility.collided?(object1, object2) object1_radius = Math.sqrt(object1.size / Math::PI) object2_radius = Math.sqrt(object2.size / Math::PI) return true if find_distance(object1.location, object2.location) < object1_radius + object2_radius false end end end #module Zyps