class CommandlineUI

Constants

TTY_PROMPT_PER_PAGE

Attributes

battle[R]
map[R]
session[R]
test_mode[R]

Public Class Methods

clear_screen() click to toggle source
# File lib/natural_20/cli/commandline_ui.rb, line 96
def self.clear_screen
  puts "\e[H\e[2J"
end
new(battle, map, test_mode: false) click to toggle source

Creates an instance of a commandline UI helper @param battle [Natural20::Battle] @param map [Natural20::BattleMap]

# File lib/natural_20/cli/commandline_ui.rb, line 17
def initialize(battle, map, test_mode: false)
  @battle = battle
  @session = battle.session
  @map = map
  @test_mode = test_mode
  @renderer = Natural20::MapRenderer.new(@map, @battle)
end

Public Instance Methods

arcane_recovery_ui(entity, spell_levels) click to toggle source
# File lib/natural_20/cli/commandline_ui.rb, line 329
def arcane_recovery_ui(entity, spell_levels)
  choice = prompt.select(t('action.arcane_recovery', name: entity.name)) do |q|
    spell_levels.sort.each do |level|
      q.choice t(:spell_level, level: level), level
    end
    q.choice t('action.waive_arcane_recovery'), :waive
  end
  return nil if choice == :waive

  choice
end
attack_ui(entity, action, options = {}) click to toggle source

Create a attack target selection CLI UI @param entity [Natural20::Entity] @param action [Natural20::Action] @option options range [Integer] @options options target [Array<Natural20::Entity>] passed when there are specific valid targets

# File lib/natural_20/cli/commandline_ui.rb, line 54
def attack_ui(entity, action, options = {})
  weapon_details = options[:weapon] ? session.load_weapon(options[:weapon]) : nil
  selected_targets = []
  valid_targets = options[:targets] || battle.valid_targets_for(entity, action, target_types: options[:target_types],
                                                                                range: options[:range], filter: options[:filter])
  total_targets = options[:num] || 1
  puts t(:"multiple_targets", total_targets: total_targets) if total_targets > 1
  total_targets.times.each do |index|
    target = prompt.select("Target #{index + 1}: #{entity.name} targets") do |menu|
      valid_targets.each do |t|
        menu.choice target_name(entity, t, weapon: weapon_details), t
      end
      menu.choice 'Manual - Use cursor to select a target instead', :manual
      menu.choice 'Back', nil
    end

    return nil if target == 'Back'

    if target == :manual
      valid_targets = options[:targets] || battle.valid_targets_for(entity, action,
                                                                    target_types: options[:target_types], range: options[:range],
                                                                    filter: options[:filter],
                                                                    include_objects: true)
      selected_targets += target_ui(entity, weapon: weapon_details, validation: lambda { |selected|
                                                                                  selected_entities = map.thing_at(*selected)

                                                                                  if selected_entities.empty?
                                                                                    return false
                                                                                  end

                                                                                  selected_entities.detect do |selected_entity|
                                                                                    valid_targets.include?(selected_entity)
                                                                                  end
                                                                                })
    end

    selected_targets << target
  end

  selected_targets.flatten
end
battle_ui(chosen_characters) click to toggle source

Starts a battle @param chosen_characters [Array]

# File lib/natural_20/cli/commandline_ui.rb, line 453
def battle_ui(chosen_characters)
  battle.map.activate_map_triggers(:on_map_entry, nil, ui_controller: self)
  battle.register_players(chosen_characters, self)
  chosen_characters.each do |entity|
    entity.attach_handler(:opportunity_attack, self, :opportunity_attack_listener)
  end
  game_loop
end
describe_map(map, line_of_sight: []) click to toggle source
# File lib/natural_20/cli/commandline_ui.rb, line 359
def describe_map(map, line_of_sight: [])
  line_of_sight = [line_of_sight] unless line_of_sight.is_a?(Array)
  pov = line_of_sight.map(&:name).join(',')
  puts t('map_description', width: map.size[0], length: map.size[1], feet_per_grid: map.feet_per_grid, pov: pov)
end
game_loop() click to toggle source
# File lib/natural_20/cli/commandline_ui.rb, line 407
def game_loop
  Natural20::EventManager.set_context(battle, battle.current_party)

  result = battle.while_active do |entity|
    start_combat = false
    if battle.has_controller_for?(entity)
      cycles = 0
      move_path = []
      loop do
        cycles += 1
        session.save_game(battle)
        action = battle.move_for(entity)

        if action.nil?

          unless battle.current_party.include?(entity)
            describe_map(battle.map, line_of_sight: battle.current_party)
            puts @renderer.render(line_of_sight: battle.current_party, path: move_path)
          end
          prompt.keypress(t(:end_turn, name: entity.name)) unless battle.current_party.include? entity
          move_path = []
          break
        end

        move_path += action.move_path if action.is_a?(MoveAction)

        battle.action!(action)
        battle.commit(action)

        if battle.check_combat
          start_combat = true
          break
        end
        break if action.nil?
      end
    end

    start_combat
  end
  prompt.keypress(t(:tpk)) if result == :tpk
  puts '------------'
  puts t(:battle_end, num: battle.round + 1)
end
move_for(entity, battle) click to toggle source

Return moves by a player using the commandline UI @param entity [Natural20::Entity] The entity to compute moves for @param battle [Natural20::Battle] An instance of the current battle @return [Array]

# File lib/natural_20/cli/commandline_ui.rb, line 369
def move_for(entity, battle)
  puts ''
  puts "#{entity.name}'s turn"
  puts '==============================='
  loop do
    describe_map(battle.map, line_of_sight: entity)
    puts @renderer.render(line_of_sight: entity)
    puts t(:character_status_line, ac: entity.armor_class, hp: entity.hp, max_hp: entity.max_hp, total_actions: entity.total_actions(battle), bonus_action: entity.total_bonus_actions(battle),
                                   available_movement: entity.available_movement(battle), statuses: entity.statuses.to_a.join(','))
    entity.active_effects.each do |effect|
      puts t(:effect_line, effect_name: effect[:effect].label, source: effect[:source].name)
    end
    action = prompt.select(t('character_action_prompt', name: entity.name, token: entity.token&.first), per_page: TTY_PROMPT_PER_PAGE,
                                                                                                        filter: true) do |menu|
      entity.available_actions(@session, battle).each do |a|
        menu.choice a.label, a
      end
      # menu.choice 'Console (Developer Mode)', :console
      menu.choice 'End'.colorize(:red), :end
    end

    if action == :console
      prompt.say('battle - battle object')
      prompt.say('entity - Current Player/NPC')
      prompt.say('@map - Current map')
      binding.pry
      next
    end

    return nil if action == :end

    action = action_ui(action, entity)
    next if action.nil?

    return action
  end
end
move_ui(entity, _options = {}) click to toggle source

@param entity [Natural20::Entity]

# File lib/natural_20/cli/commandline_ui.rb, line 213
def move_ui(entity, _options = {})
  path = [map.position_of(entity)]
  toggle_jump = false
  jump_index = []
  test_jump = []
  loop do
    puts "\e[H\e[2J"
    movement = map.movement_cost(entity, path, battle, jump_index)
    movement_cost = "#{(movement.cost * map.feet_per_grid).to_s.colorize(:green)}ft."
    if entity.prone?
      puts "movement (crawling) #{movement_cost}"
    elsif toggle_jump && !jump_index.include?(path.size - 1)
      puts "movement (ready to jump) #{movement_cost}"
    elsif toggle_jump
      puts "movement (jump) #{movement_cost}"
    else
      puts "movement #{movement_cost}"
    end
    describe_map(battle.map, line_of_sight: entity)
    puts @renderer.render(entity: entity, line_of_sight: entity, path: path, update_on_drop: true,
                          acrobatics_checks: movement.acrobatics_check_locations, athletics_checks: movement.athletics_check_locations)
    prompt.say('(warning) token cannot end its movement in this square') unless @map.placeable?(entity, *path.last,
                                                                                                battle)
    prompt.say('(warning) need to perform a jump over this terrain') if @map.jump_required?(entity, *path.last)
    directions = []
    directions << '(wsadx) - movement, (qezc) diagonals'
    directions << 'j - toggle jump' unless entity.prone?
    directions << 'space/enter - confirm path'
    directions << 'r - reset'
    movement = prompt.keypress(directions.join(','))

    if movement == 'w'
      new_path = [path.last[0], path.last[1] - 1]
    elsif movement == 'a'
      new_path = [path.last[0] - 1, path.last[1]]
    elsif movement == 'd'
      new_path = [path.last[0] + 1, path.last[1]]
    elsif %w[s x].include?(movement)
      new_path = [path.last[0], path.last[1] + 1]
    elsif [' ', "\r"].include?(movement)
      next unless valid_move_path?(entity, path, battle, @map, manual_jump: jump_index)

      return [path, jump_index]
    elsif movement == 'q'
      new_path = [path.last[0] - 1, path.last[1] - 1]
    elsif movement == 'e'
      new_path = [path.last[0] + 1, path.last[1] - 1]
    elsif movement == 'z'
      new_path = [path.last[0] - 1, path.last[1] + 1]
    elsif movement == 'c'
      new_path = [path.last[0] + 1, path.last[1] + 1]
    elsif movement == 'r'
      path = [map.position_of(entity)]
      jump_index = []
      toggle_jump = false
      next
    elsif movement == 'j' && !entity.prone?
      toggle_jump = !toggle_jump
      next
    elsif movement == "\e"
      return nil
    else
      next
    end

    next if new_path[0].negative? || new_path[0] >= map.size[0] || new_path[1].negative? || new_path[1] >= map.size[1]

    test_jump = jump_index + [path.size] if toggle_jump

    if path.size > 1 && new_path == path[path.size - 2]
      jump_index.delete(path.size - 1)
      path.pop
      toggle_jump = if jump_index.include?(path.size - 1)
                      true
                    else
                      false
                    end
    elsif valid_move_path?(entity, path + [new_path], battle, @map, test_placement: false, manual_jump: test_jump)
      path << new_path
      jump_index = test_jump
    elsif valid_move_path?(entity, path + [new_path], battle, @map, test_placement: false, manual_jump: jump_index)
      path << new_path
      toggle_jump = false
    end
  end
end
opportunity_attack_listener(battle, session, entity, map, event) click to toggle source
# File lib/natural_20/cli/commandline_ui.rb, line 462
def opportunity_attack_listener(battle, session, entity, map, event)
  entity_x, entity_y = map.position_of(entity)
  target_x, target_y = event[:position]

  distance = Math.sqrt((target_x - entity_x)**2 + (target_y - entity_y)**2).ceil

  possible_actions = entity.available_actions(session, battle, opportunity_attack: true).select do |s|
    weapon_details = session.load_weapon(s.using)
    distance <= weapon_details[:range]
  end

  return nil if possible_actions.blank?

  action = prompt.select(t('action.opportunity_attack', name: entity.name, target: event[:target].name)) do |menu|
    possible_actions.each do |a|
      menu.choice a.label, a
    end
    menu.choice t(:waive_opportunity_attack), :waive
  end

  return nil if action == :waive

  if action
    action.target = event[:target]
    action.as_reaction = true
    return action
  end

  nil
end
prompt() click to toggle source

@return [TTY::Prompt]

# File lib/natural_20/cli/commandline_ui.rb, line 499
def prompt
  @@prompt ||= if test_mode
                 TTY::Prompt::Test.new
               else
                 TTY::Prompt.new
               end
end
prompt_hit_die_roll(entity, die_types) click to toggle source
# File lib/natural_20/cli/commandline_ui.rb, line 320
def prompt_hit_die_roll(entity, die_types)
  prompt.select(t('dice_roll.hit_die_selection', name: entity.name, hp: entity.hp, max_hp: entity.max_hp)) do |menu|
    die_types.each do |t|
      menu.choice "d#{t}", t
    end
    menu.choice t('skip_hit_die'), :skip
  end
end
roll_for(entity, die_type, number_of_times, description, advantage: false, disadvantage: false) click to toggle source
# File lib/natural_20/cli/commandline_ui.rb, line 300
def roll_for(entity, die_type, number_of_times, description, advantage: false, disadvantage: false)
  return nil unless @session.setting(:manual_dice_roll)

  prompt.say(t('dice_roll.prompt', description: description, name: entity.name.colorize(:green)))
  number_of_times.times.map do |index|
    if advantage || disadvantage
      2.times.map do |index|
        prompt.ask(t("dice_roll.roll_attempt_#{advantage ? 'advantage' : 'disadvantage'}", total: number_of_times, die_type: die_type,
                                                                                           number: index + 1)) do |q|
          q.in("1-#{die_type}")
        end
      end.map(&:to_i)
    else
      prompt.ask(t('dice_roll.roll_attempt', die_type: die_type, number: index + 1, total: number_of_times)) do |q|
        q.in("1-#{die_type}")
      end.to_i
    end
  end
end
show_message(message) click to toggle source
# File lib/natural_20/cli/commandline_ui.rb, line 493
def show_message(message)
  puts ''
  prompt.keypress(message)
end
spell_slots_ui(entity) click to toggle source
# File lib/natural_20/cli/commandline_ui.rb, line 341
def spell_slots_ui(entity)
  puts t(:spell_slots)
  (1..9).each do |level|
    next unless entity.max_spell_slots(level).positive?

    used_slots = entity.max_spell_slots(level) - entity.spell_slots(level)
    used = used_slots.times.map do
      '■'
    end

    avail = entity.spell_slots(level).times.map do
      '°'
    end

    puts t(:spell_level_slots, level: level, slots: (used + avail).join(' '))
  end
end
target_name(entity, target, weapon: nil) click to toggle source
# File lib/natural_20/cli/commandline_ui.rb, line 25
def target_name(entity, target, weapon: nil)
  cover_ac = cover_calculation(@map, entity, target)
  target_labels = []
  target_labels << target.name.colorize(:red)
  target_labels << "(cover AC +#{cover_ac})" if cover_ac.positive?
  if weapon
    advantage_mod, adv_info = target_advantage_condition(battle, entity, target, weapon)
    adv_details, disadv_details = adv_info
    target_labels << t(:with_advantage) if advantage_mod.positive?
    target_labels << t(:with_disadvantage) if advantage_mod.negative?

    reasons = []
    adv_details.each do |d|
      reasons << "+#{t("attack_status.#{d}")}".colorize(:blue)
    end
    disadv_details.each do |d|
      reasons << "-#{t("attack_status.#{d}")}".colorize(:red)
    end

    target_labels << reasons.join(',')
  end
  target_labels.join(' ')
end
target_ui(entity, initial_pos: nil, num_select: 1, validation: nil, perception: 10, weapon: nil, look_mode: false) click to toggle source

@param entity [Natural20::Entity]

# File lib/natural_20/cli/commandline_ui.rb, line 101
def target_ui(entity, initial_pos: nil, num_select: 1, validation: nil, perception: 10, weapon: nil, look_mode: false)
  selected = []
  initial_pos ||= map.position_of(entity)
  new_pos = nil
  loop do
    CommandlineUI.clear_screen
    highlights = map.highlight(entity, perception)
    prompt.say(t('perception.looking_around', perception: perception))
    describe_map(battle.map, line_of_sight: entity)
    puts @renderer.render(line_of_sight: entity, select_pos: initial_pos, highlight: highlights)
    puts "\n"
    things = map.thing_at(*initial_pos, reveal_concealed: true)

    prompt.say(t('object.ground')) if things.empty?

    if map.can_see_square?(entity, *initial_pos)
      prompt.say(t('perception.using_darkvision')) unless map.can_see_square?(entity, *initial_pos,
                                                                              allow_dark_vision: false)
      things.each do |thing|
        prompt.say(target_name(entity, thing, weapon: weapon)) if thing.npc?

        prompt.say("#{thing.label}:")

        if !@battle.can_see?(thing, entity) && thing.sentient? && thing.conscious?
          prompt.say(t('perception.hide_success', label: thing.label))
        end

        map.perception_on(thing, entity, perception).each do |note|
          prompt.say("  #{note}")
        end
        health_description = thing.try(:describe_health)
        prompt.say("  #{health_description}") unless health_description.blank?
      end

      map.perception_on_area(*initial_pos, entity, perception).each do |note|
        prompt.say(note)
      end

      prompt.say(t('perception.terrain_and_surroundings'))
      terrain_adjectives = []
      terrain_adjectives << 'difficult terrain' if map.difficult_terrain?(entity, *initial_pos)

      intensity = map.light_at(initial_pos[0], initial_pos[1])
      terrain_adjectives << if intensity >= 1.0
                              'bright'
                            elsif intensity >= 0.5
                              'dim'
                            else
                              'dark'
                            end

      prompt.say("  #{terrain_adjectives.join(', ')}")
    else
      prompt.say(t('perception.dark'))
    end

    movement = prompt.keypress(look_mode ? t('perception.navigation_look') : t('perception.navigation'))

    if movement == 'w'
      new_pos = [initial_pos[0], initial_pos[1] - 1]
    elsif movement == 'a'
      new_pos = [initial_pos[0] - 1, initial_pos[1]]
    elsif movement == 'd'
      new_pos = [initial_pos[0] + 1, initial_pos[1]]
    elsif movement == 's'
      new_pos = [initial_pos[0], initial_pos[1] + 1]
    elsif ['x', ' ', "\r"].include? movement
      next if validation && !validation.call(new_pos)

      selected << initial_pos
    elsif movement == 'r'
      new_pos = map.position_of(entity)
      next
    elsif movement == "\e"
      return []
    else
      next
    end

    next if new_pos.nil?
    next if new_pos[0].negative? || new_pos[0] >= map.size[0] || new_pos[1].negative? || new_pos[1] >= map.size[1]
    next unless map.line_of_sight_for?(entity, *new_pos)

    initial_pos = new_pos

    break if ['x', ' ', "\r"].include? movement
  end

  selected = selected.compact.map { |e| map.thing_at(*e) }
  selected_targets = []
  targets = selected.flatten.select { |t| t.hp && t.hp.positive? }.flatten.uniq

  if targets.size > 1
    loop do
      target = prompt.select(t('multiple_target_prompt')) do |menu|
        targets.flatten.uniq.each do |t|
          menu.choice t.name.to_s, t
        end
      end
      selected_targets << target
      break unless selected_targets.size < expected_targets
    end
  else
    selected_targets = targets
  end

  return nil if selected_targets.blank?

  selected_targets
end