AndyObtiva/glimmer-dsl-libui

View on GitHub
examples/tetris/model/game.rb

Summary

Maintainability
B
4 hrs
Test Coverage
# Copyright (c) 2021-2024 Andy Maleh
#
# Permission is hereby granted, free of charge, to any person obtaining
# a copy of this software and associated documentation files (the
# "Software"), to deal in the Software without restriction, including
# without limitation the rights to use, copy, modify, merge, publish,
# distribute, sublicense, and/or sell copies of the Software, and to
# permit persons to whom the Software is furnished to do so, subject to
# the following conditions:
#
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

require 'fileutils'
require 'json'
require 'glimmer/data_binding/observer'
require 'glimmer/config'

require_relative 'block'
require_relative 'tetromino'
require_relative 'past_game'

class Tetris
  module Model
    class Game
      PLAYFIELD_WIDTH = 10
      PLAYFIELD_HEIGHT = 20
      PREVIEW_PLAYFIELD_WIDTH = 4
      PREVIEW_PLAYFIELD_HEIGHT = 2
      SCORE_MULTIPLIER = {1 => 40, 2 => 100, 3 => 300, 4 => 1200}
      UP_ARROW_ACTIONS = %i[instant_down rotate_right rotate_left]
      SPEEDS = %i[snail sloth turtle rabbit gorilla bear horse gazelle cheetah falcon]
      SPEED_INITIAL_DELAYS = SPEEDS.each_with_index.inject({}) {|hash, speed_index_pair| hash.merge(speed_index_pair.first => 1.1 - speed_index_pair.last*(0.1)) }
      
      attr_reader :playfield_width, :playfield_height
      attr_accessor :game_over, :paused, :preview_tetromino, :lines, :score, :level, :high_scores, :beeping, :added_high_score, :show_high_scores, :up_arrow_action, :show_preview_tetromino, :initial_delay
      alias game_over? game_over
      alias paused? paused
      alias beeping? beeping
      alias added_high_score? added_high_score
      alias show_preview_tetromino? show_preview_tetromino
      
      def initialize(playfield_width = PLAYFIELD_WIDTH, playfield_height = PLAYFIELD_HEIGHT)
        @initial_delay = SPEED_INITIAL_DELAYS[:snail]
        @playfield_width = playfield_width
        @playfield_height = playfield_height
        @high_scores = []
        @show_high_scores = false
        @beeping = true
        @up_arrow_action = :rotate_left
        @show_preview_tetromino = true
        load_high_scores!
      end
      
      def configure_beeper(&beeper)
        @beeper = beeper
      end
      
      def game_in_progress?
        !game_over? && !paused?
      end
      
      def start!
        self.show_high_scores = false
        self.paused = false
        self.level = 1
        self.score = 0
        self.lines = 0
        reset_playfield
        reset_preview_playfield
        reset_tetrominoes
        preview_next_tetromino!
        consider_adding_tetromino
        self.game_over = false
      end
      alias restart! start!
      
      def game_over!
        add_high_score!
        beep
        self.game_over = true
      end
      
      def clear_high_scores!
        high_scores.clear
        save_high_scores!
      end
      
      def add_high_score!
        self.added_high_score = true
        high_scores.prepend(PastGame.new("Player #{high_scores.count + 1}", score, lines, level))
        save_high_scores!
      end
      
      def save_high_scores!
        high_score_file_content = @high_scores.map {|past_game| past_game.to_a.join("\t") }.join("\n")
        FileUtils.mkdir_p(tetris_dir)
        File.write(tetris_high_score_file, high_score_file_content)
      rescue => e
        # Fail safely by keeping high scores in memory if unable to access disk
        Glimmer::Config.logger.error {"Failed to save high scores in: #{tetris_high_score_file}\n#{e.full_message}"}
      end
      
      def load_high_scores!
        if File.exist?(tetris_high_score_file)
          self.high_scores = File.read(tetris_high_score_file).split("\n").map {|line| PastGame.new(*line.split("\t")) }
        end
      rescue => e
        # Fail safely by keeping high scores in memory if unable to access disk
        Glimmer::Config.logger.error {"Failed to load high scores from: #{tetris_high_score_file}\n#{e.full_message}"}
      end
      
      def tetris_dir
        @tetris_dir ||= File.join(Dir.home, '.glimmer-tetris')
      end
      
      def tetris_high_score_file
        File.join(tetris_dir, "high_scores.txt")
      end
      
      def down!(instant: false)
        return unless game_in_progress?
        current_tetromino.down!(instant: instant)
        game_over! if current_tetromino.row <= 0 && current_tetromino.stopped?
      end
      
      def right!
        return unless game_in_progress?
        current_tetromino.right!
      end
      
      def left!
        return unless game_in_progress?
        current_tetromino.left!
      end
      
      def rotate!(direction)
        return unless game_in_progress?
        current_tetromino.rotate!(direction)
      end
      
      def current_tetromino
        tetrominoes.last
      end
    
      def tetrominoes
        @tetrominoes ||= reset_tetrominoes
      end
      
      # Returns blocks in the playfield
      def playfield
        @playfield ||= @original_playfield = @playfield_height.times.map do
          @playfield_width.times.map do
            Block.new
          end
        end
      end
      
      # Executes a hypothetical scenario without truly changing playfield permanently
      def hypothetical(&block)
        @playfield = hypothetical_playfield
        block.call
        @playfield = @original_playfield
      end
      
      # Returns whether currently executing a hypothetical scenario
      def hypothetical?
        @playfield != @original_playfield
      end
      
      def hypothetical_playfield
        @playfield_height.times.map { |row|
          @playfield_width.times.map { |column|
            playfield[row][column].clone
          }
        }
      end
      
      def preview_playfield
        @preview_playfield ||= PREVIEW_PLAYFIELD_HEIGHT.times.map {|row|
          PREVIEW_PLAYFIELD_WIDTH.times.map {|column|
            Block.new
          }
        }
      end
      
      def preview_next_tetromino!
        self.preview_tetromino = Tetromino.new(self)
      end
      
      def calculate_score!(eliminated_lines)
        new_score = SCORE_MULTIPLIER[eliminated_lines] * (level + 1)
        self.score += new_score
      end
      
      def level_up!
        self.level += 1 if lines >= self.level*10
      end
      
      def delay
        [@initial_delay - (level.to_i * 0.1), 0.001].max
      end
      
      def beep
        @beeper&.call if beeping
      end

      SPEED_INITIAL_DELAYS.each do |speed, speed_initial_day|
        define_method("speed_#{speed}=") do |is_true|
          self.initial_delay = speed_initial_day if is_true
        end
        
        define_method("speed_#{speed}") do
          self.initial_delay == speed_initial_day
        end
      end
      
      UP_ARROW_ACTIONS.each do |up_arrow_action_symbol|
        define_method("#{up_arrow_action_symbol}_on_up=") do |is_true|
          self.up_arrow_action = up_arrow_action_symbol if is_true
        end
        
        define_method("#{up_arrow_action_symbol}_on_up") do
          self.up_arrow_action == up_arrow_action_symbol
        end
      end
      
      def reset_tetrominoes
        @tetrominoes = []
      end
      
      def reset_playfield
        playfield.each do |row|
          row.each do |block|
            block.clear
          end
        end
      end
      
      def reset_preview_playfield
        preview_playfield.each do |row|
          row.each do |block|
            block.clear
          end
        end
      end
      
      def consider_adding_tetromino
        if tetrominoes.empty? || current_tetromino.stopped?
          preview_tetromino.launch!
          preview_next_tetromino!
        end
      end
      
      def consider_eliminating_lines
        eliminated_lines = 0
        playfield.each_with_index do |row, playfield_row|
          if row.all? {|block| !block.clear?}
            eliminated_lines += 1
            shift_blocks_down_above_row(playfield_row)
          end
        end
        if eliminated_lines > 0
          beep
          self.lines += eliminated_lines
          level_up!
          calculate_score!(eliminated_lines)
        end
      end
            
      def playfield_remaining_heights(tetromino = nil)
        @playfield_width.times.map do |playfield_column|
          bottom_most_block = tetromino.bottom_most_block_for_column(playfield_column)
          (playfield.each_with_index.detect do |row, playfield_row|
            !row[playfield_column].clear? &&
            (
              tetromino.nil? ||
              bottom_most_block.nil? ||
              (playfield_row > tetromino.row + bottom_most_block[:row_index])
            )
          end || [nil, @playfield_height])[1]
        end.to_a
      end
      
      private
      
      def shift_blocks_down_above_row(row)
        row.downto(0) do |playfield_row|
           playfield[playfield_row].each_with_index do |block, playfield_column|
             previous_row = playfield[playfield_row - 1]
             previous_block = previous_row[playfield_column]
             block.color = previous_block.color unless block.color == previous_block.color
           end
        end
        playfield[0].each(&:clear)
      end
      
    end
  
  end

end