#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 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.
#
# DESCRIPTION: 2015 asteriods
#
# Run the script with
#   klayout -r path_to_script/2015.rb ...
# (it is important to specify a path, i.e. ".")
# or drag and drop the script to the KLayout window.

# Requires Qt binding
if ! RBA.const_defined?("QDialog")
  raise "KLayout must be compiled with --with-qtbinding"
end

# Requires at least 0.23.1
v = RBA::Application::instance.version.split(/\./)
3.times { |i| v[i] = (v[i] || "0").to_i }
if v[0] == 0 && (v[1] < 23 || (v[1] == 23 && v[2] < 1))
  raise "At least KLayout 0.23.1 required"
end

module RBA

  # extend RBA::DBox by a method to confine a point within it
  class DBox

    # Returns the point p, but confined within the box if outside
    # Note: the solution is not necessarily efficient!
    def confine_point(p)

      if !self.contains?(p)

        while p.x < self.left
          p.x += self.width
        end
        while p.x > self.right
          p.x -= self.width
        end
        while p.y < self.bottom
          p.y += self.height
        end
        while p.y > self.top
          p.y -= self.height
        end

      end

      p

    end

  end

end

# A base class for an object on the scene
# Objects need to be able to register themselves in the game. They must
# provide a cell and an instance of that cell. The cell holds the drawing
# and the instance defines the position and orientation of that drawing.
# Objects receive "tick" calls in regular intervals at which they can perform
# some incrementation action. While doing so, the objects can remove other
# objects or themselves using "vanish".
class Object

  # Register the object at the game object with the given cell and instance
  def setup(game, cellname)

    @cell = game.layout.create_cell(cellname)

    @game = game
    @game.register(self) 

    @inst = game.top.insert(RBA::CellInstArray::new(cell.cell_index, RBA::CplxTrans::new))

  end

  # Returns the cell index
  def cell_index
    @cell.cell_index
  end

  # Returns the cell object
  def cell
    @cell
  end

  # Returns the instance
  def inst
    @inst
  end

  # Gets the game object this object is registered on
  # When the object has been removed with "vanish", this
  # reference is nil.
  def game
    @game
  end
  
  # Sets the position of the object
  # The position is specified in database units
  def pos=(p)
    @game || return
    t = @inst.cplx_trans
    t.disp = RBA::Vector::new(p.x, p.y)
    @inst.cplx_trans = t
  end
  
  # Gets the position of the object
  def pos
    @game ? @inst.cplx_trans * RBA::Point::new : RBA::Point::new
  end

  # Rotates the object by the given angle (in degree)
  def rotate(d)
    @game || return
    t = @inst.cplx_trans
    t.angle = t.angle + d
    @inst.cplx_trans = t
  end
  
  # Removes the object from the scene and finally deletes it
  def vanish
    @game || return
    @game.unregister(self)
    @game.top.erase(@inst)
    @cell.delete 
    @game = nil
  end

  # Tests whether this object hits others
  def hit_test

    @game || return

    hits = []

    box1 = @inst.bbox
    cbox1 = @cell.bbox 
    d1 = 0.25 * (cbox1.width + cbox1.height)

    @game.top.each_touching_inst(box1) do |i|

      if i.cell_index != @cell.cell_index

        hit = @game.object_for_cell(i.cell_index)
        if hit

          box2 = hit.inst.bbox
          cbox2 = hit.cell.bbox 
          d2 = 0.25 * (cbox2.width + cbox2.height)

          d = box1.center.distance(box2.center)

          if d < d1 + d2
            hits << hit
          end

        end

      end

    end

    hits.each do |h|
      h.hit_by(self)
    end

  end
  
  # This method is called periodically on the objects registered in the game scene
  def tick
  end

  # This method is called when another object hits self
  def hit_by(other)
  end

end

# The rocket object
class Rocket < Object
  
  # Initializer
  def initialize(game)
  
    setup(game, "ROCKET")
    
    @v = RBA::Vector::new 
    @accel = false
    
  end
  
  # Accelerates by the given value (DBU/tick)
  def accel(d)
    a = (@inst.cplx_trans.angle + 90) / 180.0 * Math::PI
    @v += RBA::DPoint::new(Math.cos(a), Math.sin(a)) * d;
    @accel = true
  end
  
  # Periodic callback - see Object
  # The rocket will move with the given velocity and direction 
  # and update the drawing to reflect acceleration mode
  def tick

    p = self.pos + @v
    self.pos = self.game.vpbox.confine_point(p)

    ly = self.game.layout
    top = self.game.top

    li = ly.layer(1, 0)
    @cell.shapes(li).clear

    pts = [
      RBA::Point::new(0, 500),
      RBA::Point::new(300, -500),
      RBA::Point::new(0, -250),
      RBA::Point::new(-300, -500)
    ]
    @cell.shapes(li).insert(RBA::Polygon::new(pts))

    if @accel
      pts = [
        RBA::Point::new(-150, -600),
        RBA::Point::new(150, -600),
        RBA::Point::new(-150, -800),
        RBA::Point::new(150, -800)
      ]
      @cell.shapes(li).insert(RBA::Path::new(pts, 0))
      @accel = false
    end
  
  end

  # Fires a shot
  def fire

    a = (@inst.cplx_trans.angle + 90) / 180.0 * Math::PI
    v = RBA::DVector::new(Math.cos(a), Math.sin(a))
    vv = 400 + 100 * rand

    Shot::new(@game, v * vv, self.pos + v * 500)

  end

  # Hit test: if hit by anything else, make the rocket vanish 
  # and finally the game end
  def hit_by(other)
    if other.is_a?(Asteroid) || other.is_a?(Saucer) || other.is_a?(Shot)
      Smoke::new(@game, self.pos, 50)
      self.vanish
      other.vanish
    end
  end

end

# The rocket indicator object
# This object is used to draw the remaining lives
class RocketIndicator < Object
  
  # Initializer
  def initialize(game)
  
    setup(game, "IROCKET")
    
    ly = self.game.layout
    li = ly.layer(2, 0)
    self.cell.shapes(li).clear

    pts = [
      RBA::Point::new(0, 250),
      RBA::Point::new(150, -250),
      RBA::Point::new(0, -125),
      RBA::Point::new(-150, -250)
    ]
    self.cell.shapes(li).insert(RBA::Polygon::new(pts))

  end
  
end

# A shot propagating along the trajectory
class Shot < Object

  # Initializer
  # v is the shot direction and velocity in DBU/tick
  # pos is the initial position
  def initialize(game, v, pos)
  
    setup(game, "SHOT")

    self.pos = pos

    li = game.layout.layer(1, 0)
    pts = [
      RBA::Point::new + (v * (-50 / v.abs)),
      RBA::Point::new + (v * (50 / v.abs))
    ]
    self.cell.shapes(li).insert(RBA::Path::new(pts, 0))

    @v = v
    @accel = false

  end

  # Periodic callback - see Object
  def tick

    p = self.pos + @v

    if !self.game.vpbox.contains?(p)
      self.vanish
    else
      self.pos = p
    end

  end

end

# A flying saucer object
# The saucer will move along the given trajectory until it leaves the scene.
# Occasionally it will fire a shot towards the rocket.
class Saucer < Object

  # Initializer
  # v is the movement direction and velocity
  # pos is the initial position
  def initialize(game, v, pos)
  
    setup(game, "SAUCER")

    self.pos = pos
    
    li = game.layout.layer(1, 0)

    pts = [
      RBA::Point::new(-1000, -300), 
      RBA::Point::new(-800, 0), 
      RBA::Point::new(800, 0), 
      RBA::Point::new(1000, -300) 
    ]
    cell.shapes(li).insert(RBA::Polygon::new(pts))

    pts = [
      RBA::Point::new(-500, 0), 
      RBA::Point::new(-300, 200), 
      RBA::Point::new(0, 300), 
      RBA::Point::new(300, 200), 
      RBA::Point::new(500, 0) 
    ]
    cell.shapes(li).insert(RBA::Polygon::new(pts))

    @v = v

    lib = RBA::Library::library_by_name("Basic")
    text_pcell = lib.layout.pcell_declaration("TEXT")

    p = { 
      "text" => "TRY THIS WITH YOUR TOOL", 
      "layer" => RBA::LayerInfo::new(2, 0), 
      "mag" => 200.0 / 1000.0 
    }
    parameters = text_pcell.get_parameters.collect do |pd|
      p[pd.name] || pd.default
    end
    tmp_cell_index = self.game.layout.add_pcell_variant(lib, text_pcell.id, parameters)
    tmp_cell = self.game.layout.cell(tmp_cell_index)

    li = self.game.layout.layer(2, 0)

    text_shapes = tmp_cell.shapes(li)

    d = tmp_cell.bbox.center
    t = RBA::Trans::new(-d.x, -550)
    
    self.cell.shapes(li).clear
    text_shapes.each do |s|
      self.cell.shapes(li).insert(s, t)
    end

    tmp_cell.delete
    
  end
  
  # Periodic callback - see Object
  def tick

    p = @inst.cplx_trans.disp + @v

    if !self.game.vpbox.contains?(p)
      self.vanish
    else

      self.pos = p

      if rand < 0.01 && self.game.rocket

        ptarget = self.game.rocket.pos

        v = ptarget - self.pos
        v = v * (1.0 / v.abs)
        vv = (400 + 100 * rand)

        Shot::new(@game, v * vv, self.pos + v * 1500)

      end  

    end

  end

  # Hit test: the saucer vanishes if hit by a shot
  def hit_by(other)

    if other.is_a?(Shot)
      Smoke::new(@game, self.pos)
      self.game.add_score(1000)
      self.vanish
      other.vanish
    end

  end

end

# An asteroid object
class Asteroid < Object

  # Initializer
  # v is the movement direction and velocity
  # pos is the initial position
  # size is the diameter of the asteroid
  def initialize(game, v, pos, size)
  
    setup(game, "ASTEROID")

    self.pos = pos
    
    li = game.layout.layer(1, 0)
    pts = []
    10.times do |i|
      r = (0.15 + rand * 0.35) * size
      pts << RBA::Point::new((r * Math::cos(i * 0.2 * Math::PI)).to_i, (r * Math::sin(i * 0.2 * Math::PI)).to_i)
    end
    self.cell.shapes(li).insert(RBA::Polygon::new(pts))

    @v = v
    @accel = false
    @size = size

  end
  
  # Periodic callback - see Object
  def tick

    p = @inst.cplx_trans.disp + @v

    t = @inst.cplx_trans
    t.disp = self.game.vpbox.confine_point(p)
    t.angle = t.angle + 5 
    @inst.cplx_trans = t

  end

  # Hit test: the asteroid breaks into pieces or vanishes
  # below a threshold size if hit by a shot
  def hit_by(other)

    if other.is_a?(Shot)

      self.game.add_score(200)

      if @size > 1500
        3.times do 
          a = Math::PI * 2 * rand
          Asteroid::new(@game, RBA::DPoint::new(Math::cos(a), Math::sin(a)) * @v.abs, self.pos, @size / 3)
        end
      else
        Smoke::new(@game, self.pos)
      end

      self.vanish
      other.vanish

    end

  end

end

# An explosion object
# The explosion will emit a number of sparks which will sparkle while they 
# propagate outwards
class Smoke < Object

  # Initializer
  # pos is the position of the explosion
  # n is the number of sparks and also the time to live
  def initialize(game, pos, n = 20)
  
    setup(game, "SMOKE")

    self.pos = pos

    @sparks = []
    n.times do 
      a = Math::PI * 2 * rand
      v = 50 * rand
      @sparks << [ RBA::Point::new((Math::cos(a) * v).to_i, (Math::sin(a) * v).to_i), RBA::Point::new ]
    end
    
    @ttl = n

  end
  
  # Periodic callback - see Object
  # This method will move the sparks and occasionally disable them to make them
  # "sparkle". When the TTL (time to live) has expired, the object vanishes.
  def tick

    @ttl -= 1
    if @ttl < 0
      self.vanish
    else

      @sparks.each do |s|
        s[1] += s[0]
      end

      ly = self.game.layout

      li = ly.layer(1, 0)
      @cell.shapes(li).clear

      @sparks.each do |s|
        if rand > 0.7
          d = RBA::Point::new(20, 20)
          @cell.shapes(li).insert(RBA::Box::new(s[1] - d, s[1] + d))
        end
      end

    end

  end

end

# An object to display a text (i.e. "GAME OVER")
# Texts are drawn on a different layer (2/0) than the game scene
# to allow texts to be filled (avoids ugly connection lines)
class TextDisplay < Object

  # Initializer
  # pos is the position of the text (see also halign and valign)
  # size is the text height
  # halign is 0 for center, -1 for right and 1 for left
  # valign is 0 for center, -1 for top and 1 for bottom
  def initialize(game, pos, size, halign, valign)
  
    setup(game, "TEXT")

    self.pos = pos

    @halign = halign
    @valign = valign
    @size = size

  end
  
  # Sets the text to be displayed
  def set_text(text)

    text == @text && return
    @text = text

    lib = RBA::Library::library_by_name("Basic")
    text_pcell = lib.layout.pcell_declaration("TEXT")

    p = { "text" => text, "layer" => RBA::LayerInfo::new(2, 0), "mag" => @size.to_f / 1000.0 }
    parameters = text_pcell.get_parameters.collect do |pd|
      p[pd.name] || pd.default
    end
    tmp_cell_index = self.game.layout.add_pcell_variant(lib, text_pcell.id, parameters)
    tmp_cell = self.game.layout.cell(tmp_cell_index)

    li = self.game.layout.layer(2, 0)

    text_shapes = tmp_cell.shapes(li)

    d = tmp_cell.bbox.center
    t = RBA::Trans::new(d.x * (@halign - 1), d.y * (@valign - 1))
    
    @cell.shapes(li).clear
    text_shapes.each do |s|
      @cell.shapes(li).insert(s, t)
    end

    tmp_cell.delete
    
  end

end

# The game controller
# The game controller will instantiate the game scene, register actions to 
# handle key events, provide a timer for the motion and so forth.
# It also provides a state machine to coordinate the phases of the game.
class Asteroids

  # Initializer
  def initialize

    mw = RBA::Application.instance.main_window

    # Create an action for "accelerate"
    accel = RBA::Action::new
    accel.shortcut = "Up"
    accel.title = "A"
    accel.on_triggered { self.accel_clicked }

    # Create an action for "hyperspace jump"
    hyperspace = RBA::Action::new
    hyperspace.shortcut = "Down"
    hyperspace.title = "D"
    hyperspace.on_triggered { self.hyperspace_clicked }

    # Create an action for "rotate left"
    left = RBA::Action::new
    left.shortcut = "Left"
    left.title = "L"
    left.on_triggered { self.left_clicked }

    # Create an action for "rotate right"
    right = RBA::Action::new
    right.shortcut = "Right"
    right.title = "R"
    right.on_triggered { self.right_clicked }

    # Create an action for "fire"
    fire = RBA::Action::new
    fire.shortcut = "Space"
    fire.title = "F"
    fire.on_triggered { self.fire_clicked }

    # Inserts the actions in the menu - otherwise key events
    # will not be caught
    menu = mw.menu
    menu.insert_separator("@toolbar.end", "sep")
    menu.insert_item("@toolbar.end", "accel", accel)
    menu.insert_item("@toolbar.end", "left", left)
    menu.insert_item("@toolbar.end", "right", right)
    menu.insert_item("@toolbar.end", "fire", fire)
    menu.insert_item("@toolbar.end", "deaccel", hyperspace)

    @objects = {} 
    
    # Specify the tick value (the time between two steps on the scene)
    @tick = 0.05

    # create a new layout and configure the corresponding view properly
    # with black background and some features disabled
    # We need an editable layout (otherwise the shape object magic won't work).
    # "show_layout" is a trick to establish a layout in editable mode even if the 
    # program is not called in editable mode:
    @ly = RBA::Layout::new(true)
    mw.create_layout("", 0)
    @lv = mw.current_view
    @lv.show_layout(@ly, false)
    @lv.set_config("background-color", "#000000")
    @lv.set_config("bitmap-oversampling", "1")
    @lv.set_config("text-visible", "true")
    @lv.set_config("sel-transient-mode", "false")
    @lv.set_config("grid-visible", "false")
    @lv.set_config("drawing-workers", "0") # reduces flicker
    @lv.set_config("hide-empty-layers", "false") # saves time
    @lv.set_config("hide-empty-layers", "false") # saves time
    @lv.clear_layers

    # A hollow layer for the scene
    lp = RBA::LayerProperties::new
    lp.dither_pattern = 1
    lp.frame_color = 0xffffff
    lp.fill_color = 0xffffff
    lp.source_layer = 1
    lp.source_datatype = 0
    @lv.insert_layer(@lv.end_layers, lp)

    # A filled layer for the texts
    lp = RBA::LayerProperties::new
    lp.dither_pattern = 0
    lp.frame_color = 0xffffff
    lp.fill_color = 0xffffff
    lp.source_layer = 2
    lp.source_datatype = 0
    @lv.insert_layer(@lv.end_layers, lp)

    # Enable 2 hierarchy levels
    @lv.max_hier_levels = 2

    # Create a top cell
    @top = @ly.create_cell("TOP")
    @lv.select_cell(@top.cell_index, 0)
        
    # Zoom to a given scene rectangle and remember the 
    # resulting box.
    @lv.zoom_box(RBA::DBox::new(-10, -10, 10, 10))
    @vpbox = self.view.box * (1.0 / self.layout.dbu)

    # Set up the timer
    @timer = RBA::QTimer.new
    @timer.interval = @tick * 1000
    @timer.singleShot = false
    @timer.timeout do 
      self.tick
    end

    # Set up the state machine
    @state = :initialized

    # Produce the greeting text
    texts = [
      "UP    - ACCELERATE",
      "LEFT  - ROTATE LEFT",
      "RIGHT - ROTATE RIGHT",
      "DOWN  - JUMP",
      "SPACE - FIRE",
      nil,
      "PRESS SPACE TO BEGIN"
    ]

    y = self.vpbox.top - 6000
    x = self.vpbox.center.x - 10000
    texts.each do |l|
      if l
        td = TextDisplay::new(self, RBA::DPoint::new(x, y), 1500, 1, 0)
        td.set_text(l)
      end 
      y -= 1500
    end

  end

  # Starts the game
  def start_game
    @score = 0
    @nlives = 5
    setup(0)
    @state = :playing
    self.start
  end

  # Handler for the "accelerate" key event
  def accel_clicked
    if @state == :playing
      @rocket && @rocket.game && @rocket.accel(5)
    else
      self.start_game
    end
  end

  # Handler for the "hyperspace" key event
  def hyperspace_clicked
    if @state == :playing
      if @rocket && @rocket.game
        x = (self.vpbox.left + self.vpbox.width * rand).to_i
        y = (self.vpbox.bottom + self.vpbox.height * rand).to_i
        @rocket.pos = RBA::DPoint::new(x, y)
      end
    else
      self.start_game
    end
  end
      
  # Handler for the "rotate left" key event
  def left_clicked
    if @state == :playing
      @rocket && @rocket.game && @rocket.rotate(10)
    else
      self.start_game
    end
  end

  # Handler for the "rotate right" key event
  def right_clicked
    if @state == :playing
      @rocket && @rocket.game && @rocket.rotate(-10)
    else
      self.start_game
    end
  end

  # Handler for the "fire" key event
  def fire_clicked
    if @state == :playing
      @rocket && @rocket.game && @rocket.fire
    else
      self.start_game
    end
  end

  # Clean up all objects from the scene
  def cleanup

    # makes sure there are no interactions with selection and such:
    @lv.cancel
    @lv.clear_transactions

    objs =  @objects.collect { |id,obj| obj }
    objs.each do |obj|
      obj.vanish
    end

  end

  # Stops the game and show the "GAME OVER" message
  def stop_game

    self.stop
    self.cleanup

    td = TextDisplay::new(self, RBA::DPoint::new(self.vpbox.center.x, self.vpbox.top - 5000), 3000, 0, -1)
    td.set_text("GAME OVER!")

    td = TextDisplay::new(self, RBA::DPoint::new(self.vpbox.center.x, self.vpbox.top - 13000), 1500, 0, -1)
    td.set_text("PRESS SPACE TO CONTINUE")

    @state = :continue

  end

  # Sets up the scene and internals for the given level (0, 1, ...)
  def setup(level)

    self.cleanup

    @lv.zoom_box(self.vpbox * self.layout.dbu)

    @level = level

    @score_display = TextDisplay::new(self, RBA::DPoint::new(self.vpbox.left + 200, self.vpbox.top - 200), 1000, 1, -1)
    add_score(0)

    x = self.vpbox.left + 4500
    @rocket_indicators = []
    @nlives.times do |i|
      r = RocketIndicator::new(self)
      @rocket_indicators << r
      r.pos = RBA::DPoint::new(x, self.vpbox.top - 550)
      x += 500
    end

    @rocket = Rocket::new(self)

    (5 + level * 2).times do |i|
      a = Math::PI * 2 * rand
      v = 200 + level * 50
      Asteroid::new(self, RBA::DPoint::new(Math::cos(a), Math::sin(a)) * v, RBA::DPoint::new(self.vpbox.right, self.vpbox.top), 3000)
    end

  end

  # Adds a value to the score value
  def add_score(d)
    @score += d
    @score_display.set_text(("%06d" % @score).gsub(/0/, "O"))  # "O" does not have a bar
  end

  # Gets the rocket object or nil if there is no more rocket
  def rocket
    if @rocket && @rocket.game
      return @rocket
    else
      return nil
    end
  end

  # Gets the layout object
  def layout
    @ly
  end

  # Gets the top cell
  def top
    @top
  end

  # Gets the layout view
  def view
    @lv
  end

  # Gets the view box
  def vpbox
    @vpbox
  end

  # Play the script: this method will evaluate the script in the context of 
  # "self" and start the timer
  def start
    stop
    @timer.start 
  end

  # Stop the script execution
  def stop
    @timer.stop
  end

  # Adds the given object to the scene
  def register(obj)
    @objects[obj.object_id] = obj
  end

  # Removes the given object from the scene
  def unregister(obj)
    @objects.delete(obj.object_id)
  end

  # Finds the object for a given cell index
  def object_for_cell(cell_index)
    return @objects.values.find { |obj| obj.cell_index == cell_index }
  end

  # Timer callback: perform the next action
  def tick

    # stop if the view was closed
    if @lv.destroyed?
      stop
      return
    end

    # clear selection and transactions which may depend
    # on any object we will delete now
    @lv.cancel
    @lv.clear_transactions

    # occasionally create a saucer object
    if rand < 0.002 + 0.001 * @level
      ypos = self.vpbox.bottom + rand * self.vpbox.height
      Saucer::new(self, RBA::DPoint::new(300, 0), RBA::DPoint::new(vpbox.left, ypos))
    end

    # call "tick" on each object - since "tick" may unregister the 
    # object we cannot iterate over the hash directly.
    objs =  @objects.collect { |id,obj| obj }
    objs.each do |obj|
      obj.game && obj.tick
    end
 
    # call "hit_test" on each object - since "hit_test" may unregister objects 
    # object we cannot iterate over the hash directly.
    objs =  @objects.collect { |id,obj| obj }
    objs.each do |obj|
      obj.game && obj.hit_test
    end
 
    # Count the remaining asteroids, smoke (explosion) and rocket objects
    asteroids = 0
    rockets = 0
    smoke = 0
    @objects.each do |id,obj|
      if obj.is_a?(Asteroid)
        asteroids += 1
      elsif obj.is_a?(Smoke)
        smoke += 1
      elsif obj.is_a?(Rocket)
        rockets += 1
      end
    end

    # No more rocket and smoke is gone -> GAME OVER or create a new rocket
    if smoke == 0 && rockets == 0
      @nlives -= 1
      @rocket_indicators[@nlives].vanish
      if @nlives == 0
        self.stop_game
      else
        @rocket = Rocket::new(self)
      end
    # No more asteroids and smoke is gone -> next level
    elsif smoke == 0 && asteroids == 0
      setup(@level + 1)  
    end

  end

end

# The main action of this module: instantiate the script player
# and initiate the playing of the script
$asteroids = Asteroids.new

# After the player has been started, run the real application
# (this will do nothing if the script has been started by dragging
# it into the already running application)
RBA::Application.instance.exec

