# Copyright 2007 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
def initialize (objects = [], environmental_factors = [])
self.objects, self.environmental_factors = objects, environmental_factors
@clock = Clock.new
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 EnvironmentalFactor is allowed to act on each object.
#Finally, each GameObject with an act() method 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 environmental factors interact with each object.
environmental_factors.each do |factor|
begin
factor.act(object)
#Remove misbehaving environmental factors.
rescue Exception => exception
environmental_factors.delete(factor)
puts exception, exception.backtrace
next
end
end
#Have all creatures interact with the environment.
if object.respond_to?(:act)
begin
object.act(self)
#Remove misbehaving objects.
rescue Exception => exception
puts exception, exception.backtrace
objects.delete(object)
next
end
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
def initialize (name = nil, location = Location.new, color = Color.new, vector = Vector.new, age = 0, size = 1, tags = [])
self.name, self.location, self.color, self.vector, self.age, self.size, self.tags = name, location, color, vector, age, size, 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.
def initialize (name = nil, location = Location.new, color = Color.new, vector = Vector.new, age = 0, size = 1, tags = [], behaviors = [])
super(name, location, color, vector, age, size, tags)
self.behaviors = 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
#Call Behavior.perform(self, environment) on each of the creature's assigned Behaviors.
def act(environment)
behaviors.each {|behavior| behavior.perform(self, environment)}
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
#Action subclasses must implement a do(actor, target) instance method.
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
end
#A condition for one Creature to act on another.
class Condition
#Make a deep copy.
def copy; self.clone; end
#Condition subclasses must implement a met?(actor, target) instance method.
def met?(actor, target)
raise NotImplementedError.new("Condition subclasses must implement a met?(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
#A list of Condition objects, which are called with the object itself and its target. A condition can consider the tags on the target, the distance from the subject, or any other criteria. If any condition returns false, the behavior will not be carried out (or stopped if it has begun).
attr_accessor :conditions
#A list of Action objects, which are called with the object and its target when all conditions are met. An action can act on the subject or its target.
attr_accessor :actions
#Optionally takes an array of actions and one of conditions.
def initialize (actions = [], conditions = [])
self.actions, self.conditions = actions, conditions
#Tracks current target.
@active_target = nil
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
#Test all conditions against each object in the evironment.
#For the first object that meets all of them, mark it active (and operate on it first next time).
#Then call start() (if applicable) and perform() for all actions against the active target.
#If any action or condition returns false, stop all actions, and deselect the active target.
def perform(actor, environment)
begin
#Select a target.
target = select_target(actor, environment.objects)
#Do the actions on the target.
actions.each do |action|
action.start(actor, target) unless action.started
action.do(actor, target)
end
rescue NoMatchException => exception
#If the behavior can no longer be performed, halt it.
stop(actor, target)
end
end
private
#Stop all actions and de-select the active target.
def stop(actor, target)
actions.each do |action|
action.stop(actor, target) if action.started
end
@active_target = nil
end
#Select a target that matches all conditions.
def select_target(actor, targets)
#If a target is already active, still present in the environment, and all conditions are true for it, simply re-select it.
if @active_target and targets.include?(@active_target) and conditions.all?{|condition| condition.met?(actor, @active_target)}
return @active_target
end
#For each object in environment:
targets.each do |target|
#Don't let actor target itself.
next if target == actor
#If all conditions match (or there are no conditions), select the object.
if conditions.all?{|condition| condition.met?(actor, target)}
@active_target = target
return target
end
end
#If there were no matches, throw an exception.
raise NoMatchException, "No matching targets found."
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:
#Get the angle (in degrees) from one Location to another.
def Utility.find_angle(origin, target)
#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.
to_degrees(radians)
end
#Get the distance from one Location to another.
def Utility.find_distance(origin, target)
#Get vector from origin to target.
x_difference = origin.x - target.x
y_difference = origin.y - target.y
#Get distance.
Math.sqrt(x_difference ** 2 + y_difference ** 2)
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
class NoMatchException < RuntimeError; end
end #module Zyps