# Tankspank version 4, 7-April-2008
#
# This code is hereby released into the public domain
# http://creativecommons.org/licenses/publicdomain/
#
# Originally by kevin conner <connerk@gmail.com>
#
# Updated  Ernest Prabhakar <ernest.prabhakar@gmail.com>

# Notable Features:
# * Abstract classes "Shape" for static objects, "Sprite" for moving objects
# * Damage, drag calculated dynamically based on size
# * Converts between "game" and "screen" coordinates using moving Camera
# * Use isometric projection (draw_box) for 2.5D rendering
# * AppTest proxy object allows minimal testing outside Shoes runtime

require 'matrix'
require 'logger'

#
#  Constants 
#

$debug = true
$debug2 = false
$log = Logger.new(STDERR)
$log.level = Logger::DEBUG

X=0
Y=1

SCREEN_SIZE = Vector[700.0, 500.0]
SCREEN_ORIGIN = SCREEN_SIZE * 0.5
Z_SCALE = 600.0
FRAMES_PER_SECOND=10
EXPLODE_RATE = 1.0/FRAMES_PER_SECOND

SHELL_SPEED = 20
SHELL_SIZE = Vector[6.0,1.0]
TANK_SIZE = Vector[10.0,15.0]
MUZZLE_SIZE = Vector[25.0,0.0]
BUMPER_RADIUS = 3
AIM_RADIUS = 10
DEST_RADIUS = 20

TIGHTNESS = 0.1
IMPULSE = 1.0
DRAG_MAX = 0.20

#
# Extensions to built-in Ruby classes
#


class Matrix
  
  def self.rotation(angle)
		cos_a, sin_a = Math.cos(angle), Math.sin(angle)
		Matrix[ [cos_a, -sin_a], [sin_a, cos_a] ]
  end

  def self.reflection
		Matrix[ [1,0], [0, -1] ]
  end

end

class Vector

  def abs; Vector[@elements[X].abs, @elements[Y].abs]; end

  def rotateBy radians; Matrix.rotation(radians) * self; end

  def reflection; Matrix.reflection * self; end

  def reflectBy radians; reflection.rotateBy(radians); end
  
  def angle; Math.atan2(@elements[Y], @elements[X]); end

  def ratio; @elements[Y] / @elements[X]; end

  def area; @elements.inject(4) {|x,y| x*y}; end
  
  def within?(distance)
    each2(distance) do |self_i, distance_i|
      return false if self_i.abs > distance_i
    end
    true
  end

  def to_s; to_a.inject("[") {|p,q| p += sprintf(" %.0f",q)} + "]"; end

end

def bound(low, x, high)
  return low if x < low
  return high if x > high
  x
end

def angle_bound(t)
  return t+2*Math::PI if t < -Math::PI
  return t-2*Math::PI if t > Math::PI
  t
end

#
# Mixin Modules
#

module Drawable

	def draw_rect
	  $app.rect *base_extent
  end

	def draw_rect_with_corners screen_corners
	  base = screen_corners[2]
	  extent = screen_corners[0] - screen_corners[2]
	  $app.rect *(base.to_a + extent.to_a)
  end
  
	def draw_oval
	  $app.oval *base_extent
  end

  def draw_poly(points)
	  points.inject(points[-1]) do |p, q|
	    $app.line *(p.to_a + q.to_a); q
    end
  end

	def draw_outline
	  points = screen_corners()
	  draw_poly(points)
  end
  
	def draw_along(vector)
	  p = screen_coord()
	  q = p + vector
	  $app.line *(p.to_a + q.to_a)
  end
  
  def draw_box
    bottom = screen_corners()
		top = screen_corners(@height)
    $log.debug "#{$camera}" if $debug2
	  $log.debug "\nbottom| #{bottom.to_s}\n   top| #{top.to_s}" if $debug2
 		draw_outline
		4.times { |i| $app.line *(bottom[i].to_a + top[i].to_a) }
		draw_rect_with_corners top
	end

	def draw
	  return if not $camera.intersect? self
	  $log.debug "\nDrawing #{self}" if $debug
	  $app.stroke @stroke_color
		$app.fill @fill_color
		draw_content
  end
  
  def draw_content; draw_rect; end

end

module Hurtable

	attr_reader :health

	def reset_health; @health = mass(); end

	def strength; @health / mass(); end

	def dead?; @health <= 0; end

	def hurt damage; @health -= damage; end
	
	def encounter obstacle
	  damage = [energy(), obstacle.energy()].max
	  hurt damage
	  obstacle.hurt damage
  end
	
end

#
# Static "Shapes"
#

class Shape
  include Drawable
	include Hurtable
  attr_accessor :center, :size, :stroke_color, :fill_color
  
  def initialize(center, size)
    @center = center.clone()
    @size = size
    @height = @size.r * (0.25 + rand * 1.5)
    color = (1..3).collect { 0.2 + 0.4 * rand }
		color << 0.75
		@stroke_color = $app.rgb *color
		color[-1] = 0.9
		@fill_color = $app.rgb *color
		reset_health()
  end
  
  def to_s
    s = "#{self.class}:#{@center.to_s}+-#{@size.to_s}"
    s += sprintf(" x %.0f", @height)
  end
  
  def front; @height * @size[X]; end
  
  def volume; @height * @size.area; end

  def mass; volume(); end

  def energy; 0; end
  
  def screen_coord(dz=1); SCREEN_ORIGIN + (@center - $camera.center) * dz; end

	def base_extent
	  $log << "screen_coord = #{screen_coord} for camera=#{$camera}\n" if $debug2
	  (screen_coord - @size).to_a + (@size * 2).to_a
  end

  # Center +-  sizes/ reflected size
  def cornerize(ctr, s, sR)
    [ctr+s, ctr-sR, ctr-s, ctr+sR]
  end

	def corners; cornerize(@center, @size, @size.reflection); end

	def front_corners; c = corners(); [c[-1],c[0]]; end
	
	def faces; cornerize(@center, Vector[@size[X],0], Vector[0,@size[Y]]); end

	def screen_corners(height = 0)
    dz = (height == 0) ? 1 : 1 + (2/Math::PI) * Math.atan(height / Z_SCALE)
	  ctr =  screen_coord(dz)
	  psize = @size * dz
	  cornerize(ctr, psize, psize.reflection)  
  end

	def contain? point; (point - @center).within? @size; end
	#treat as rectangular
	
	def intersect? shape
	  $log << "#{corners} vs.\n\t#{shape.corners}\n" if $debug2
	  self.corners.any? { |point| shape.contain? point } or
	  shape.corners.any? { |point| self.contain? point } 
	end
  
end


class Building < Shape
	
	def initialize(west, east, north, south)
	  center = Vector[(west+east)/2.0, (south+north)/2.0]
    size = Vector[(west-east).abs/2.0, (south-north)/2.0]
		super(center, size)
	end
	
	def draw_content; draw_box; end
  
end

class Boundary < Building
	def initialize(west, east, north, south)
	  super
	  @fill_color = $app.black
		@stroke_color = $app.red(0.9)
		@height = Z_SCALE/2
  end  
end


class Circle < Shape
	
	def initialize(center, radius, color)
    size = Vector[radius, radius]
		super(center, size)
		@stroke_color = color
		@fill_color = color
	end
	
	def draw_content; draw_oval; end
  
  def corners; faces(); end

  def contain? point; (point - @center).r < @size.r; end
	
end

#
# Moving "Sprites"
#

class Sprite < Shape
  
  attr_reader :facing, :goal
  
  def turn_radius; 2*@size[Y]; end

  def drag; [DRAG_MAX, @size.ratio**2].min; end
  
  def initialize(center, size)    
    super
		@last = @center
    @facing = 0
    @acceleration = 0.0
    @speed = 0.0
    @turn_max = 1/turn_radius
    @decay = 1 - drag
    @goal = nil
    @stroke_color = $app.blue
  end
  
  def set_goal pos, radius, color
    @goal = Circle.new pos, radius, color
  end

  def energy; @speed**2; end

  def turn
    return if @goal.nil?
    
    delta = @goal.center - @center
    turn_by = angle_bound(delta.angle - @facing)
    @facing += bound(-1*@turn_max, (turn_by), @turn_max)
    @acceleration = Math.cos(turn_by) * IMPULSE * strength()
  end
  
  def move
    @speed += @acceleration 
    @speed *= @decay
		@last = @center
    @center += Vector[1,0].rotateBy(@facing) * @speed
  end
 
	def arrive
		$log.info "Arrived #{self}"
 	  @acceleration = 0
		@goal = nil
	end
	
	def collide
		$log.info "Stopping: #{self}"
		arrive
		@center = @last
		@speed = 0
	end

	def at_goal?; not @goal.nil? and intersect?(@goal); end
	
  def update
    turn()
    move()
    arrive if at_goal?
  end

  def corners
	  cornerize(@center, @size.rotateBy(@facing), @size.reflectBy(@facing))
  end

  def screen_corners
	  ctr = screen_coord()
	  cornerize(ctr, @size.rotateBy(@facing), @size.reflectBy(@facing))
  end
	
  def draw_content
    draw_outline
    @goal.draw if not @goal.nil?
  end
  
end

class Camera < Sprite
	def initialize to_follow
	  super to_follow.center, SCREEN_ORIGIN
	  @goal = to_follow
  end
  
  def update; @center += (@goal.center - @center) * TIGHTNESS; end
  
  def game_coord(x, y); Vector[x,y] - SCREEN_ORIGIN + @center; end
  
end

class Explosion < Sprite
  
	def initialize shape
	  super(shape.center, shape.size)
	  @fill_color = $app.yellow
	  @stroke_color = shape.stroke_color
	  @r_max = @size.r * 2
	  @r_min = @size.r / 2
	  @expand = true
	end

	def update
	  @size *= 1 + (@expand ? +EXPLODE_RATE : -EXPLODE_RATE)
	  set_goal(@center, @size.to_a.min / 2.0, $app.red) 
	  @expand = false if @size.r > @r_max
	end
  
	def draw_content
    draw_oval
    @goal.draw# if not @goal.nil?
  end

  def dead?; @size.r < @r_min; end

end

class Shell < Sprite
  
	def initialize position, angle
	  position += MUZZLE_SIZE.rotateBy(angle)
	  super(position, SHELL_SIZE)
	  @facing = angle
	  @speed = SHELL_SPEED
	  @fill_color = @stroke_color = $app.red
	end
	
	def dead?; @speed < 1; end
  
end

class Turret < Sprite
  
  def initialize(center, size)    
	  super
	end
	
  def draw_content
    super
    muzzle = MUZZLE_SIZE.rotateBy(@facing)
    draw_along(muzzle)
  end
	
end

class Tank < Sprite
	
	def initialize(center)
	  super(center, TANK_SIZE)
	  @last = @center
		@turret = Turret.new(center, TANK_SIZE*0.5)
	end
	
  def dest_point= pos
    set_goal(pos, DEST_RADIUS, $app.green)
  end

  def aim_point= pos
    $log.debug "aim: #{pos}" if $debug
    @turret.set_goal(pos, AIM_RADIUS, $app.red)
  end

  def fire
    $log.debug "firing at #{@turret.goal}" if $debug
    Shell.new(@center, @turret.facing)
  end
			
  def update
    super
    @turret.turn()
    @turret.center = @center
    
  end
  
  def headlights
	  front_corners().inject([]) do |l,c|
	    l << Circle.new(c, BUMPER_RADIUS, $app.cyan)
    end
  end

	def draw
    super
    @turret.draw
    headlights().each {|b| b.draw}
	end
end

#
# Game Management
#

class TankSpank
  
	def initialize
		@boundary = Boundary.new(-1500, 1500, -1250, 1250)
		@shapes = [
			[-1000, -750, -750, -250],
			[-500, 250, -750, -250],
			[500, 1000, -750, -500],
			[750, 1250, -250, 0],
			[750, 1250, 250, 750],
			[250, 500, 0, 750],
			[-250, 0, 0, 500],
			[-500, 0, 750, 1000],
			[-1000, -500, 0, 500],
			[400, 600, -350, -150]
		].collect { |p| Building.new *p }
		@tank = Tank.new(SCREEN_ORIGIN + Vector[-150,-350])
		@shapes << @tank
		$camera = Camera.new(@tank)
		@tank.aim_point = Vector[0,0]
		@time = 0
	end
	
	def playing?; not @tank.dead?; end

	def status
	  s = ""
	  s += "Mouse to aim, Click to move, Space to fire, 'p' to pause, 'n' for new\n"
	  s += sprintf "Strength: %.1f ", @tank.strength * 100
	  s += sprintf "Time: %5.2f ", @time*1.0/FRAMES_PER_SECOND
	  s += sprintf "$camera: %s ", $camera.center
	end
	
	def fire
	  @shapes << @tank.fire
	end

	def follow x,y;	@tank.dest_point = $camera.game_coord(x,y); end
	
	def check shape
    if shape.dead? and not shape.instance_of? Explosion
      @shapes.delete shape
      @shapes << Explosion.new(shape)
    else
      shape.collide() if shape.respond_to? :collide
    end
	end
	
	def update x,y
	  @tank.aim_point = $camera.game_coord(x,y)
	  @time += 1
		sprites = @shapes.select {|s| s.respond_to? :update}
		sprites.each do |sprite| 
		  sprite.update		  
		  obstacles = @shapes.find_all {|s| s.intersect? sprite and s != sprite }
		  obstacles.each do |obstacle|
		    $log << "#{sprite} vs.\n\t#{obstacle}\n" if $debug
  		  sprite.encounter obstacle
  		  check sprite
  		  check obstacle
  	  end
      @shapes.delete sprite if sprite.dead? # in case died of natural causes
	  end
  end

  def draw
    $log.debug "Time=#{@time} @ #{$camera}" if $debug
		$app.clear do
			$camera.update
			@boundary.draw
			@shapes.each { |s| s.draw }
		end
	end
	
	def play(x,y); update(x,y); draw; end
	
end

#
# Testing Helpers
#

class AppTest
  def self.app(param); yield; end
  def self.method_missing sym, *args
    s = args.collect{|x|sprintf(" %.0f",x)} 
    $log << "\t#{sym}: #{s}\n" if $debug
  end
  def self.black (*args); [1]; end
  def self.red (*args); [1]; end
  def self.green (*args); [1]; end
  def self.blue (*args); [1]; end
  def self.yellow (*args); [1]; end
  def self.rgb (*args); args; end
  def self.stroke (*args); end
  def self.fill (*args); end
  def self.clear; yield; end
end


def stack; end;
def para x; $log << x; end
def banner x; $log << x; end; def caption x; $log << x; end; 
def mouse(); [1] + SCREEN_ORIGIN.to_a; end

def keypress; yield "x"; end;
def click; yield mouse(); end;
def animate(n); 3.times {yield}; end

#
# User Interaction

APP = Shoes rescue AppTest
APP.app :width => SCREEN_SIZE[X], :height => SCREEN_SIZE[Y] do
	$app = self
  $app = APP if $app.class == Object

	@game = TankSpank.new
	stack do
	  para "Game Starting...", :stroke => blue
  end
  @paused = false
  
	keypress do |key|
	  case key
      when "1", "z"
        button, x, y = mouse()
		  	@game.follow x,y if @game.playing? 
      when "2", "x", " "
		  	@game.fire if @game.playing? 
		  when "p"
		    @paused = ! @paused
		  when "n"
      	@game = TankSpank.new
  			stack do
  				banner "New Game", :stroke => white, :margin => 10
  			end
		end
	end
	
	click do |button, x, y|
		if button == 1
	  	@game.follow x, y if @game.playing? 
		else
	  	@game.fire if @game.playing? 
		end
	end
	
	animate(FRAMES_PER_SECOND) do
    button, x, y = mouse()		
	  @game.play(x,y) if not @paused
    $debug = false
		if @game.playing? 
			stack do
  			para @game.status, :stroke => white, :margin => 10
				banner "Paused", :stroke => yellow if @paused
  		end
		else
			stack do
				banner "Game Over", :stroke => white, :margin => 10
				caption "learn to drive!", :stroke => white, :margin => 20
			end
		end # else
	end # animate
end # app
