changeset 0:91ecd24948de

Imported.
author Mikhail Kryshen <mikhail@kryshen.net>
date Sat, 14 Jul 2012 05:46:29 +0400
parents
children fac1b8f35265
files .hgignore Rakefile res/net/kryshen/charamega/DejaVuSans.ttf src/net/kryshen/charamega/card.mirah src/net/kryshen/charamega/field.mirah src/net/kryshen/charamega/game.mirah src/net/kryshen/charamega/ui.mirah
diffstat 7 files changed, 1010 insertions(+), 0 deletions(-) [+]
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/.hgignore	Sat Jul 14 05:46:29 2012 +0400
@@ -0,0 +1,5 @@
+syntax: regexp
+
+~$
+^build$
+^local_
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/Rakefile	Sat Jul 14 05:46:29 2012 +0400
@@ -0,0 +1,15 @@
+require 'mirah'
+require 'rake/clean'
+
+SRC = FileList['src/**/*.mirah']
+CLEAN.include('build/**/*.class')
+
+task :default => [:compile]
+
+task :compile => SRC do
+  Mirah::compile '-d', 'build', *SRC
+end
+
+task :run => [:compile] do
+  sh 'java -cp build:res net/kryshen/charamega/Ui'
+end
Binary file res/net/kryshen/charamega/DejaVuSans.ttf has changed
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/net/kryshen/charamega/card.mirah	Sat Jul 14 05:46:29 2012 +0400
@@ -0,0 +1,100 @@
+#
+#  Copyright 2012 Mikhail Kryshen <mikhail@kryshen.net>
+#
+#  This file is part of Charamega.
+#
+#  Charamega 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 3 of the License, or
+#  (at your option) any later version.
+#
+#  Charamega 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 General Public License
+#  along with Charamega.  If not, see <http://www.gnu.org/licenses/>.
+#
+
+package net.kryshen.charamega
+
+class Card
+
+  def initialize(symbol:char, time:long)
+    @symbol = symbol
+    @angle = Math.random * Math.PI - Math.PI / 2
+
+    @flip_interval = long(2E8)
+    @remove_interval = long(2E8)
+
+    @close_time = time - @flip_interval
+    @match_time = @open_time = @close_time - 1
+  end
+
+  def symbol
+    @symbol
+  end
+
+  def angle
+    @angle
+  end
+
+  def open(time:long):void
+    @open_time = time  unless opened?
+  end
+
+  def close(time:long):void
+    @close_time = time  if opened?
+  end
+
+  def match(time:long):void
+    @match_time = time  unless matched?
+  end
+
+  def opened?
+    @close_time - @open_time <= 0
+  end
+
+  def matched?
+    @close_time - @match_time <= 0
+  end
+
+  # -1.0 - closed, 1.0 - opened.
+  def flip_state(time:long)
+    if opened?
+      s = 1.0
+    else
+      s = -1.0
+    end
+
+    t = Math.max(1, time - Math.max(@close_time, @open_time))
+    
+    return s  if t >= @flip_interval
+
+    s * (t * 2 - @flip_interval) / double(@flip_interval)
+  end   
+  
+  # 1.0 - visible, 0.0 - removed.
+  def visible_state(time:long)
+    return 1.0  if !matched?
+
+    t = Math.max(1, time - @match_time - @flip_interval / 2)
+    Math.max(0.0, 1.0 - t / double(@remove_interval))
+  end
+
+  def auto_close?(time:long)
+    opened? and time - @open_time > 2E9
+  end
+
+  def toString
+    String.valueOf(@symbol) + 
+      if matched?
+        '[matched]'
+      elsif opened?
+        '[opened]'
+      else
+        ''
+      end     
+  end
+end
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/net/kryshen/charamega/field.mirah	Sat Jul 14 05:46:29 2012 +0400
@@ -0,0 +1,243 @@
+#
+#  Copyright 2012 Mikhail Kryshen <mikhail@kryshen.net>
+#
+#  This file is part of Charamega.
+#
+#  Charamega 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 3 of the License, or
+#  (at your option) any later version.
+#
+#  Charamega 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 General Public License
+#  along with Charamega.  If not, see <http://www.gnu.org/licenses/>.
+#
+
+package net.kryshen.charamega
+
+import java.awt.*
+import java.awt.geom.*
+import java.awt.event.*
+import javax.swing.*
+import java.util.List
+
+class Field < JComponent
+
+  def initialize(game:Game)
+    @field_color = Color.WHITE
+    @border_color = Color.GRAY
+    @active_border_color = Color.DARK_GRAY
+    @symbol_color = Color.BLACK
+    @back_color = Color.LIGHT_GRAY
+    @face_color = Color.new(0xFFFDDD)
+
+    @game = game 
+
+    setOpaque true
+    setDoubleBuffered true
+
+    begin
+      source = getClass.getResource("DejaVuSans.ttf").openStream
+      font = Font.createFont(Font.TRUETYPE_FONT, source)
+    ensure
+      source.close  unless source.nil?
+    end
+    setFont font
+
+    enableEvents AWTEvent.MOUSE_EVENT_MASK
+    enableEvents AWTEvent.MOUSE_MOTION_EVENT_MASK
+  end
+
+  def processMouseEvent(event)
+    if isEnabled
+      if event.getID == MouseEvent.MOUSE_PRESSED
+        
+        @hold = hit(event.getX, event.getY)
+        
+      elsif event.getID == MouseEvent.MOUSE_RELEASED and
+          !@hold.nil? and @hold == hit(event.getX, event.getY)
+        
+        @game.open @hold
+        repaint
+      elsif event.getID == MouseEvent.MOUSE_EXITED and
+          !@hovered.nil?
+        
+        @hovered = nil
+        repaint
+      end
+    end
+
+    super
+  end
+  
+  def processMouseMotionEvent(event)
+    if isEnabled
+      h = hit(event.getX, event.getY)
+      
+      if event.getID == MouseEvent.MOUSE_DRAGGED
+        if h == @hold and h != @hovered
+          @hovered = h
+          repaint
+        end
+        
+        if h != @hold and @hold == @hovered
+          @hovered = nil
+          repaint
+        end
+        
+      elsif h != @hovered
+        @hovered = h
+        repaint
+      end
+    end
+
+    super
+  end
+
+  def paintComponent(g1)
+    g = Graphics2D(g1)
+
+    g.setRenderingHint(RenderingHints.KEY_ANTIALIASING,
+                        RenderingHints.VALUE_ANTIALIAS_ON)
+    g.setRenderingHint(RenderingHints.KEY_FRACTIONALMETRICS,
+                       RenderingHints.VALUE_FRACTIONALMETRICS_ON)
+
+    w = getWidth
+    h = getHeight
+   
+    g.setColor @field_color
+    g.fillRect 0, 0, w, h
+
+    insets = getInsets
+    w -= insets.left + insets.right
+    h -= insets.top + insets.bottom
+
+    layout = @game.layout(w, h)
+    time = System.nanoTime
+
+    max_cols = Math.ceil(double(@game.cards.size) / layout.size)
+    rh = double(h) / layout.size
+    card_size = Math.min(rh, double(w) / max_cols) / 1.4
+    font_size = card_size
+    font = g.getFont.deriveFont(float(font_size))
+    frc = g.getFontRenderContext
+
+    rm_scale = 2.0
+
+    outer = Rectangle2D.Double.new
+    inner = Rectangle2D.Double.new
+    border = Path2D.Double.new(Path2D.WIND_EVEN_ODD)
+
+    save_t = g.getTransform
+
+    y = rh / 2 + insets.top
+    layout.each do |row|
+      cw = double(w) / List(row).size
+      x = cw / 2 + insets.left
+
+      List(row).each do |e|
+        card = Card(e)
+
+        v = card.visible_state(time)
+
+        # Compute area potentially affected by the card.
+        if v < 1.0
+          paint_x = int(Math.floor(x - cw * rm_scale / 2 - 1))
+          paint_y = int(Math.floor(y - rh * rm_scale / 2 - 1))
+          paint_w = int(Math.ceil(cw * rm_scale + 2))
+          paint_h = int(Math.ceil(rh * rm_scale + 2))
+        else
+          paint_x = int(Math.floor(x - cw / 2 - 1))
+          paint_y = int(Math.floor(y - rh / 2 - 1))
+          paint_w = int(Math.ceil(cw + 2))
+          paint_h = int(Math.ceil(rh + 2))
+        end
+
+        if v > 0.0 and g.hitClip(paint_x, paint_y, paint_w, paint_h)
+          f = card.flip_state(time)
+
+          if v < 1.0 or (f > -1.0 and f < 1.0)
+            # Animation is in progress.
+            repaint 30, paint_x, paint_y, paint_w, paint_h
+          end
+
+          g.translate x, y
+
+          v_scale = rm_scale - Math.sqrt(v) * rm_scale
+          g.scale Math.abs(f) + v_scale, 1.0 + v_scale
+
+          g.rotate card.angle * v
+
+          outer.setRect(-card_size / 2, -card_size / 2,
+                        card_size, card_size)
+          inner.setRect(1 - card_size / 2, 1 - card_size / 2,
+                        card_size - 2, card_size - 2)
+          border.reset
+          border.append outer, false
+          border.append inner, false
+          
+          if f > 0
+            g.setColor with_alpha(@face_color, v)
+          else
+            g.setColor with_alpha(@back_color, v)
+          end
+
+          g.fill inner
+
+          if f > 0
+            g.setColor with_alpha(@symbol_color, Math.sqrt(v))
+
+            gv = font.createGlyphVector(frc, String.valueOf(card.symbol))
+            sb = gv.getVisualBounds
+
+            # Scale down if the glyph does not fit.
+            tolerance = 0.95
+            scale = Math.min(inner.getWidth * tolerance / sb.getWidth,
+                             inner.getHeight * tolerance / sb.getHeight)
+            save_t_2 = g.getTransform
+            g.scale scale, scale  if scale < 1.0
+
+            g.drawGlyphVector(gv, 
+                              -float(sb.getX + sb.getWidth / 2),
+                              -float(sb.getY + sb.getHeight / 2))
+
+            g.setTransform save_t_2
+          end
+
+          if card == @hovered
+            g.setColor with_alpha(@active_border_color, v)
+          else
+            g.setColor with_alpha(@border_color, v)
+          end
+
+          g.fill border
+
+          g.setTransform save_t
+        end
+
+        x += cw
+      end
+      
+      y += rh
+    end
+  end
+
+  private
+  
+  def hit(x:int, y:int)
+    insets = getInsets
+    size = getSize
+    @game.hit(x - insets.left,
+              y - insets.top,
+              size.width - insets.left - insets.right,
+              size.height - insets.top - insets.bottom)
+  end
+
+  def with_alpha(c:Color, alpha:double)
+    Color.new c.getRed, c.getGreen, c.getBlue, int(alpha * 255)
+  end
+end
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/net/kryshen/charamega/game.mirah	Sat Jul 14 05:46:29 2012 +0400
@@ -0,0 +1,384 @@
+#
+#  Copyright 2012 Mikhail Kryshen <mikhail@kryshen.net>
+#
+#  This file is part of Charamega.
+#
+#  Charamega 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 3 of the License, or
+#  (at your option) any later version.
+#
+#  Charamega 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 General Public License
+#  along with Charamega.  If not, see <http://www.gnu.org/licenses/>.
+#
+
+package net.kryshen.charamega
+
+import java.util.Collection
+import java.util.List
+import java.util.ArrayList
+import java.util.Collections
+
+class Game
+
+  def initialize
+    @symbols = ArrayList.new
+
+    add_range 0x03B1, 0x03C9                 # greek
+    add_all [0x0439, 0x044A, 0x0463, 0x0467] # cyrillic
+    add_range 0x20A0, 0x20B5                 # currency
+    add_all [0x2190, 0x2194, 0x21CC]         # arrows
+    add_all [0x221A, 0x221E, 0x222B, 0x222E] # math
+    add_all [0x2318, 0x23CE, 0x23CF, 0x23E3]
+    add_all [0x25A8, 0x25A9]
+    add_range 0x25C6, 0x25D0
+    add_all [0x25D4]
+    add_range 0x2600, 0x261A
+    add_range 0x2620, 0x2630
+    add_range 0x2638, 0x2672
+    add_range 0x267A, 0x2689
+    add_range 0x2690, 0x269C
+    add_all [0x26A0, 0x26A1]
+    add_all [0x2706, 0x2707, 0x2708, 0x2709]
+    add_all [0x270C, 0x270D, 0x270E]
+    add_all [0x2744]
+
+    self.players = 1
+  end
+
+  def shuffle
+    return false  if @shuffled
+    shuffle @cards.size / 2
+
+    true
+  end
+
+  def shuffle(npairs:int):void
+    Collections.shuffle @symbols
+    
+    cards = ArrayList.new npairs    
+    time = System.nanoTime
+
+    @symbols.subList(0, npairs).each do |c|
+      cards.add Card.new Character(c).charValue, time
+      cards.add Card.new Character(c).charValue, time
+    end
+    
+    Collections.shuffle cards
+    @cards = cards
+    @layout = List(nil)
+    @first = @second = Card(nil)
+    @non_matching = 0
+    @seconds = 0
+    @shuffled = true
+
+    compute_limits
+  end
+
+  def players=(nplayers:int):void
+    @matches = int[nplayers]
+    @player = 0
+  end
+
+  def cards
+    @cards
+  end
+
+  def start:void
+    shuffle
+    @shuffled = false
+
+    @matches.length.times { |i| @matches[i] = 0 }
+    @player = 0
+
+    @start_time = System.nanoTime
+    @playing = true
+  end
+
+  def stop:void
+    @playing = false
+
+    unless @shuffled
+      time = System.nanoTime
+      delay = long(2E8 / @cards.size)
+      
+      @layout.each do |row|
+        List(row).each do |e|
+          card = Card(e)
+          card.open time  unless card.matched?
+          time += delay
+        end
+      end
+    end
+  end
+
+  # Returns list of lists of cards for each row.
+  def layout(w:int, h:int):List
+    return @layout  unless @layout.nil? or @shuffled
+
+    n = @cards.size
+    cols = columns(float(w) / h)
+    rows = int(Math.ceil float(n) / cols)
+
+    if !@layout.nil? and 
+        @layout_cols == cols and 
+        @layout_rows == rows
+
+      return @layout
+    end
+
+    layout = ArrayList.new rows
+
+    d = cols * rows - n
+    c = 0
+    rows.times do |i|
+      k = cols
+      k -= 1  if i >= rows - d
+      
+      row = ArrayList.new k
+      k.times do
+        row.add @cards.get(c)
+        c += 1
+      end
+
+      layout.add row
+    end
+
+    Collections.shuffle layout
+
+    @layout_cols = cols
+    @layout_rows = rows
+    @layout = layout
+  end
+
+  # Called periodically by UI.
+  def update
+    time = System.nanoTime
+    @seconds = int((time - @start_time) / 1E9)
+
+    # Flip the cards back after some time.
+    if !@second.nil? and
+        @first.auto_close?(time) and @second.auto_close?(time)
+
+      @first.close time
+      @second.close time
+      return true
+    end
+
+    false
+  end
+
+  # Number of removed pairs.
+  def matched
+    matched = 0
+    @matches.each { |i| matched += i }
+    
+    matched
+  end
+
+  def finished?
+    ignore_limits = false
+    # ignore_limits = true
+
+    matched * 2 == @cards.size or
+      (!multiplayer? and !ignore_limits and
+       (@non_matching > @max_non_matching or
+        @seconds >= @max_seconds))
+  end
+
+  def winner
+    max = -1
+    winner = -1
+
+    @matches.length.times do |i|
+      if @matches[i] > max
+        max = @matches[i]
+        winner = i
+      elsif @matches[i] == max
+        winner = -1
+      end
+    end
+    
+    winner
+  end
+
+  def multiplayer?
+    @matches.length > 1
+  end
+
+  def status
+    if multiplayer?
+      sb = StringBuffer.new
+      sb.append 'Scores: '
+      @matches.length.times do |i|
+        sb.append ', '  if i > 0
+        sb.append '('  if i == @player
+        sb.append String.valueOf(@matches[i])
+        sb.append ')'  if i == @player
+      end
+      sb.append ". "
+
+      if finished?
+        w = winner
+        sb.append "Player #{w + 1} wins!"  if w >= 0
+        sb.append "Tie!"  if w < 0
+      else
+        sb.append "Player #{@player + 1}'s turn."
+      end
+
+      sb.toString
+    else
+      sec = @max_seconds - @seconds
+      time = Integer[2]
+      time[0] = Integer.valueOf(sec / 60)
+      time[1] = Integer.valueOf(sec % 60)      
+      
+      sb = StringBuffer.new
+      sb.append String.format("Time left: %02d:%02d.", time)
+      sb.append "  Non-matching: #{@non_matching}"
+      sb.append " / #{@max_non_matching}."
+
+      left = @cards.size / 2 - matched
+
+      if left == 0
+        sb.append "  You win!"
+        puts "#{@cards.size / 2},#{@non_matching},#{@seconds}"
+      else
+        sb.append "  Pars left: #{left}."
+      end
+
+      sb.toString
+    end
+  end
+
+  def open(card:Card):boolean
+    return false  if finished?
+    return false  if card.matched?
+    return false  if card.opened? and (@first.nil? or @second.nil?)
+
+    time = System.nanoTime
+
+    if @first.nil?
+      @first = card.open time
+    elsif @second.nil?
+      @second = card.open time
+    else
+      @first.close time
+      @second.close time
+      @first = card.open time
+      @second = nil
+    end
+
+    unless @second.nil? 
+      if @first.symbol == @second.symbol
+        @first.match time
+        @second.match time
+        @first = @second = nil
+        @matches[@player] += 1
+      else
+        @non_matching += 1
+        @player = (@player + 1) % @matches.length
+      end
+    end
+
+    true
+  end
+
+  def hit(x:int, y:int, w:int, h:int)
+    return nil  if x < 0 or y < 0
+
+    layout = layout(w, h)
+    i = y * layout.size / h
+
+    return nil  if i >= layout.size
+
+    row = List(layout.get i)
+    j = x * row.size / w
+    
+    return nil  if j >= row.size
+
+    Card(row.get j)
+  end
+
+  private
+
+  def compute_limits
+    pairs = @cards.size / 2
+    
+    @max_non_matching = 0
+    @max_seconds = 2
+
+    # No need to optimize this.
+    pairs.downto(3) do |i|
+      if i > 180
+        @max_non_matching += 9
+      elsif i > 150
+        @max_non_matching += 8
+      elsif i > 120
+        @max_non_matching += 7
+      elsif i > 90
+        @max_non_matching += 6
+      elsif i > 60
+        @max_non_matching += 5
+      elsif i > 30
+        @max_non_matching += 4
+      elsif i > 20
+        @max_non_matching += 3
+      elsif i > 8
+        @max_non_matching += 2
+      else
+        @max_non_matching += 1
+      end
+      
+      if i > 160
+        @max_seconds += 30
+      elsif i > 120
+        @max_seconds += 25
+      elsif i > 80
+        @max_seconds += 20
+      elsif i > 60
+        @max_seconds += 15
+      elsif i > 40
+        @max_seconds += 12
+      elsif i > 20
+        @max_seconds += 9
+      elsif i > 9
+        @max_seconds += 6
+      elsif i > 5
+        @max_seconds += 4
+      else
+        @max_seconds += 2
+      end
+    end
+  end
+
+  def columns(aspect:float)
+    n = @cards.size
+    c = Math.max(1, int(Math.ceil Math.sqrt(n * aspect)))
+    
+    loop do
+      m = n % c
+      break  if m == 0 or c - m <= n / c
+      c -= 1
+    end
+
+    c
+  end
+
+  def add_range(from:int, to:int):void
+    from.upto(to) do |i|
+      @symbols.add Character.new char(i)
+    end
+  end
+  
+  def add_all(xs:Collection)
+    xs.each do |i|
+      @symbols.add Character.new char(Number(i).intValue)
+    end
+  end
+end
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/net/kryshen/charamega/ui.mirah	Sat Jul 14 05:46:29 2012 +0400
@@ -0,0 +1,263 @@
+#
+#  Copyright 2012 Mikhail Kryshen <mikhail@kryshen.net>
+#
+#  This file is part of Charamega.
+#
+#  Charamega 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 3 of the License, or
+#  (at your option) any later version.
+#
+#  Charamega 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 General Public License
+#  along with Charamega.  If not, see <http://www.gnu.org/licenses/>.
+#
+
+package net.kryshen.charamega
+
+import java.awt.*
+import java.awt.event.*
+import javax.swing.*
+import java.net.URI
+
+class Ui < JPanel
+  @@title = 'Charamega'
+  @@version = '0.9'
+  @@home = 'http://kryshen.net/games/'
+
+  def initialize(root_pane:JRootPane)
+    super LayoutManager(BorderLayout.new)
+
+    @game = Game.new.shuffle(20)
+    @field = Field.new(@game)
+    @field.setBorder BorderFactory.createEmptyBorder(5, 5, 5, 5)
+
+    add @field, BorderLayout.CENTER
+    add create_status, BorderLayout.SOUTH
+
+    ui = self
+    @timer = Timer.new(250) { |e| ui.update }
+
+    @conf = create_configurator
+    root_pane.getLayeredPane.add @conf, JLayeredPane.MODAL_LAYER
+    root_pane.setDefaultButton @start
+
+    stop
+    update_status
+  end
+
+  def doLayout
+    @conf.setSize @conf.getPreferredSize
+    @conf.setLocation int(getWidth * 0.05), int(getHeight * 0.05)
+    super
+  end
+
+  def update
+    @field.repaint  if @game.update
+    stop  if @game.finished?
+    update_status
+  end
+
+  def update_status
+    @status.setText(@game.status)
+  end
+
+  def start
+    @conf.setVisible false
+    @field.setEnabled true
+    @start.setEnabled false
+    @stop.setEnabled true
+    @game.start
+    @timer.start
+    @field.repaint
+  end
+  
+  def stop
+    @timer.stop
+    @game.stop
+    @stop.setEnabled false
+    @start.setEnabled true
+    @field.setEnabled false
+    @conf.setVisible true
+    @field.repaint
+  end
+
+  private
+
+  def create_status
+    ui = self
+
+    status_panel = JPanel.new
+    layout = GroupLayout.new(status_panel)
+    status_panel.setLayout layout
+
+    @status = JLabel.new('', SwingConstants.RIGHT)
+    @stop = JButton.new('New game')
+    @stop.addActionListener do |e|
+      ui.update_status
+      ui.stop
+    end
+    
+    gap = 5
+
+    h_group = layout.createSequentialGroup
+      .addGap(1, gap, gap)
+      .addComponent(@stop)
+      .addGap(1, gap, gap)
+      .addComponent(@status, 
+                    GroupLayout.DEFAULT_SIZE,
+                    GroupLayout.DEFAULT_SIZE,
+                    Short.MAX_VALUE)
+      .addGap(1, gap, gap)
+
+    v_group = layout.createSequentialGroup
+      .addGap(1, gap, gap)
+      .addGroup(layout.createParallelGroup(GroupLayout.Alignment.BASELINE)
+                  .addComponent(@stop)
+                  .addComponent(@status))
+      .addGap(1, gap, gap)
+    
+    layout.setHorizontalGroup h_group
+    layout.setVerticalGroup v_group 
+
+    status_panel
+  end
+
+  def create_configurator
+    # Can't use object fields in closures in Mirah 0.11.
+    ui = self
+    game = @game
+    field = @field
+
+    conf = JPanel.new
+    layout = GroupLayout.new(conf)
+    conf.setLayout layout
+    conf.setBorder BorderFactory.createRaisedBevelBorder
+
+    layout.setAutoCreateGaps true
+    layout.setAutoCreateContainerGaps true
+
+    title = JLabel.new(@@title)
+      .setFont(Font.new('serif', Font.BOLD, 26))
+
+    version = JLabel.new("version #{@@version}")
+    link = HyperlinkLabel.new(@@home)
+
+    players_label = JLabel.new('Players: ')
+    pairs_label = JLabel.new('Pairs: ')
+
+    players = SpinnerNumberModel.new(1, 1, 20, 1)
+    pairs = SpinnerNumberModel.new(20, 2, 200, 5)
+
+    players_spinner = JSpinner.new(players)
+    pairs_spinner = JSpinner.new(pairs)
+    
+    players.addChangeListener do |e|
+      game.players = players.getNumber.intValue
+      field.repaint  if game.shuffle
+      ui.update_status
+    end
+
+    pairs.addChangeListener do |e|
+      game.shuffle pairs.getNumber.intValue
+      field.repaint
+      ui.update_status
+    end
+
+    @start = JButton.new("Start")
+    @start.addActionListener do |e|
+      ui.start
+    end
+
+    h_group = layout.createParallelGroup
+      .addGroup(layout.createSequentialGroup
+                  .addComponent(title)
+                  .addComponent(version))
+      .addGroup(layout.createSequentialGroup
+                  .addComponent(link,
+                                GroupLayout.DEFAULT_SIZE,
+                                GroupLayout.DEFAULT_SIZE,
+                                GroupLayout.PREFERRED_SIZE)
+                  .addGap(0, 0, Short.MAX_VALUE))
+      .addGroup(layout.createSequentialGroup
+                  .addGap(0, 0, Short.MAX_VALUE)
+                  .addGroup(layout.createParallelGroup
+                              .addComponent(pairs_label)
+                              .addComponent(players_label))
+                  .addGroup(layout.createParallelGroup
+                              .addComponent(pairs_spinner)
+                              .addComponent(players_spinner))
+                  .addGap(0, 0, Short.MAX_VALUE))
+      .addGroup(layout.createSequentialGroup
+                  .addGap(0, 0, Short.MAX_VALUE)
+                  .addComponent(@start))
+
+    v_group = layout.createSequentialGroup
+      .addGroup(layout.createBaselineGroup(false, true)
+                  .addComponent(title)
+                  .addComponent(version))
+      .addComponent(link)
+      .addGap(25)
+      .addGroup(layout.createBaselineGroup(false, true)
+                  .addComponent(pairs_label)
+                  .addComponent(pairs_spinner))
+      .addGroup(layout.createBaselineGroup(false, true)
+                  .addComponent(players_label)
+                  .addComponent(players_spinner))
+      .addGap(15)
+      .addComponent(@start)
+    
+    layout.setHorizontalGroup h_group
+    layout.setVerticalGroup v_group 
+
+    conf
+  end
+end
+
+class HyperlinkLabel < JLabel
+
+  def initialize(uri:String)
+    initialize URI.new(uri), uri
+  end
+
+  def initialize(uri:URI)
+    initialize uri, uri.toString
+  end
+
+  def initialize(uri:URI, text:String)
+    @uri = uri
+
+    s = text.replace('&', '&amp;')
+      .replace('<', '&lt;')
+      .replace('>', '&gt;')
+
+    setText "<html><u>#{s}</u></html>"
+    setForeground Color.BLUE.darker
+    setCursor Cursor.new(Cursor.HAND_CURSOR)
+
+    enableEvents AWTEvent.MOUSE_EVENT_MASK
+  end
+
+  def processMouseEvent(event)
+    if event.getID == MouseEvent.MOUSE_CLICKED
+      Desktop.getDesktop.browse @uri
+    end
+
+    super
+  end
+end
+
+# UIManager.setLookAndFeel UIManager.getSystemLookAndFeelClassName
+
+frame = JFrame.new 'Charamega'
+ui = Ui.new(frame.getRootPane)
+
+frame.getContentPane.add ui
+frame.setDefaultCloseOperation(WindowConstants.EXIT_ON_CLOSE)
+  .setSize(800, 600)
+  .validate
+  .setVisible(true)