Class: CommandlineUI

Inherits:
Natural20::Controller show all
Includes:
Natural20::ActionUI, Natural20::Cover, Natural20::InventoryUI, Natural20::MovementHelper
Defined in:
lib/natural_20/cli/commandline_ui.rb

Constant Summary collapse

TTY_PROMPT_PER_PAGE =
20

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Methods included from Natural20::ActionUI

#action_ui, #spell_choice

Methods included from Natural20::Cover

#cover_calculation

Methods included from Natural20::MovementHelper

#compute_actual_moves, #requires_squeeze?, #retrieve_opportunity_attacks, #valid_move_path?

Methods included from Natural20::InventoryUI

#character_sheet, #how_many?, #inventory_ui

Methods included from Natural20::Weapons

#compute_advantages_and_disadvantages, #damage_modifier, #target_advantage_condition

Constructor Details

#initialize(battle, map, test_mode: false) ⇒ CommandlineUI

Creates an instance of a commandline UI helper

Parameters:



17
18
19
20
21
22
23
# 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

Instance Attribute Details

#battleObject (readonly)

Returns the value of attribute battle.



12
13
14
# File 'lib/natural_20/cli/commandline_ui.rb', line 12

def battle
  @battle
end

#mapObject (readonly)

Returns the value of attribute map.



12
13
14
# File 'lib/natural_20/cli/commandline_ui.rb', line 12

def map
  @map
end

#sessionObject (readonly)

Returns the value of attribute session.



12
13
14
# File 'lib/natural_20/cli/commandline_ui.rb', line 12

def session
  @session
end

#test_modeObject (readonly)

Returns the value of attribute test_mode.



12
13
14
# File 'lib/natural_20/cli/commandline_ui.rb', line 12

def test_mode
  @test_mode
end

Class Method Details

.clear_screenObject



96
97
98
# File 'lib/natural_20/cli/commandline_ui.rb', line 96

def self.clear_screen
  puts "\e[H\e[2J"
end

Instance Method Details

#arcane_recovery_ui(entity, spell_levels) ⇒ Object



329
330
331
332
333
334
335
336
337
338
339
# 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 = {}) ⇒ Object

Create a attack target selection CLI UI

Parameters:

Options Hash (options):

  • range (Integer)


54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
# 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) ⇒ Object

Starts a battle

Parameters:

  • chosen_characters (Array)


453
454
455
456
457
458
459
460
# 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: []) ⇒ Object



359
360
361
362
363
# 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_loopObject



407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
# 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) ⇒ Array

Return moves by a player using the commandline UI

Parameters:

Returns:

  • (Array)


369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
# 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 = {}) ⇒ Object

Parameters:



213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
# 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) ⇒ Object



462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
# 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

#promptTTY::Prompt

Returns:

  • (TTY::Prompt)


499
500
501
502
503
504
505
# 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) ⇒ Object



320
321
322
323
324
325
326
327
# 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) ⇒ Object



300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
# 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) ⇒ Object



493
494
495
496
# File 'lib/natural_20/cli/commandline_ui.rb', line 493

def show_message(message)
  puts ''
  prompt.keypress(message)
end

#spell_slots_ui(entity) ⇒ Object



341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
# 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) ⇒ Object



25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
# 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) ⇒ Object

Parameters:



101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
# 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