natural_20 0.1.1 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (73) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +1 -0
  3. data/CHANGELOG.md +13 -0
  4. data/README.md +1 -0
  5. data/bin/nat20 +2 -1
  6. data/bin/nat20.cmd +0 -0
  7. data/char_classes/wizard.yml +89 -0
  8. data/characters/high_elf_mage.yml +27 -0
  9. data/fixtures/battle_sim_objects.yml +2 -2
  10. data/fixtures/high_elf_mage.yml +28 -0
  11. data/fixtures/large_map.yml +63 -0
  12. data/game.yml +2 -2
  13. data/items/equipment.yml +30 -0
  14. data/items/objects.yml +33 -29
  15. data/items/spells.yml +58 -0
  16. data/items/weapons.yml +78 -18
  17. data/lib/CHANGELOG.md +0 -0
  18. data/lib/natural_20.rb +9 -0
  19. data/lib/natural_20/actions/action.rb +2 -2
  20. data/lib/natural_20/actions/attack_action.rb +76 -67
  21. data/lib/natural_20/actions/concerns/action_damage.rb +3 -1
  22. data/lib/natural_20/actions/dash_action.rb +7 -10
  23. data/lib/natural_20/actions/disengage_action.rb +11 -12
  24. data/lib/natural_20/actions/dodge_action.rb +7 -8
  25. data/lib/natural_20/actions/escape_grapple_action.rb +16 -18
  26. data/lib/natural_20/actions/first_aid_action.rb +14 -16
  27. data/lib/natural_20/actions/grapple_action.rb +24 -28
  28. data/lib/natural_20/actions/ground_interact_action.rb +1 -3
  29. data/lib/natural_20/actions/help_action.rb +13 -16
  30. data/lib/natural_20/actions/hide_action.rb +7 -9
  31. data/lib/natural_20/actions/interact_action.rb +12 -14
  32. data/lib/natural_20/actions/look_action.rb +14 -15
  33. data/lib/natural_20/actions/move_action.rb +9 -9
  34. data/lib/natural_20/actions/multiattack_action.rb +8 -9
  35. data/lib/natural_20/actions/prone_action.rb +4 -6
  36. data/lib/natural_20/actions/short_rest_action.rb +7 -8
  37. data/lib/natural_20/actions/shove_action.rb +20 -24
  38. data/lib/natural_20/actions/spell_action.rb +89 -0
  39. data/lib/natural_20/actions/stand_action.rb +5 -7
  40. data/lib/natural_20/actions/use_item_action.rb +7 -9
  41. data/lib/natural_20/ai_controller/standard.rb +1 -1
  42. data/lib/natural_20/battle.rb +8 -3
  43. data/lib/natural_20/cli/action_ui.rb +180 -0
  44. data/lib/natural_20/cli/builder/fighter_builder.rb +1 -1
  45. data/lib/natural_20/cli/builder/rogue_builder.rb +10 -10
  46. data/lib/natural_20/cli/builder/wizard_builder.rb +77 -0
  47. data/lib/natural_20/cli/character_builder.rb +9 -4
  48. data/lib/natural_20/cli/commandline_ui.rb +55 -162
  49. data/lib/natural_20/cli/inventory_ui.rb +4 -0
  50. data/lib/natural_20/cli/map_renderer.rb +7 -1
  51. data/lib/natural_20/concerns/attack_helper.rb +53 -0
  52. data/lib/natural_20/concerns/entity.rb +170 -11
  53. data/lib/natural_20/concerns/fighter_actions/second_wind_action.rb +7 -9
  54. data/lib/natural_20/concerns/fighter_class.rb +2 -2
  55. data/lib/natural_20/concerns/spell_attack_helper.rb +33 -0
  56. data/lib/natural_20/concerns/wizard_class.rb +86 -0
  57. data/lib/natural_20/die_roll.rb +2 -2
  58. data/lib/natural_20/event_manager.rb +50 -44
  59. data/lib/natural_20/item_library/base_item.rb +1 -1
  60. data/lib/natural_20/npc.rb +4 -0
  61. data/lib/natural_20/player_character.rb +75 -12
  62. data/lib/natural_20/session.rb +14 -1
  63. data/lib/natural_20/spell_library/firebolt.rb +72 -0
  64. data/lib/natural_20/spell_library/mage_armor.rb +67 -0
  65. data/lib/natural_20/spell_library/mage_hand.rb +2 -0
  66. data/lib/natural_20/spell_library/magic_missile.rb +67 -0
  67. data/lib/natural_20/spell_library/shield.rb +69 -0
  68. data/lib/natural_20/spell_library/spell.rb +31 -0
  69. data/lib/natural_20/utils/weapons.rb +8 -6
  70. data/lib/natural_20/version.rb +1 -1
  71. data/locales/en.yml +44 -8
  72. data/maps/game_map.yml +12 -2
  73. metadata +22 -3
@@ -1,5 +1,5 @@
1
1
  module Natural20::FighterBuilder
2
- def fighter_builder
2
+ def fighter_builder(build_values)
3
3
  @class_values ||= {
4
4
  attributes: [],
5
5
  saving_throw_proficiencies: %w[strength constitution],
@@ -1,9 +1,9 @@
1
1
  module Natural20::RogueBuilder
2
- def rogue_builder
2
+ def rogue_builder(_build_values)
3
3
  @class_values ||= {
4
4
  attributes: [],
5
5
  saving_throw_proficiencies: %w[dexterity intelligence],
6
- equipped: ['leather','dagger','dagger'],
6
+ equipped: %w[leather dagger dagger],
7
7
  inventory: [],
8
8
  tools: ['thieves_tools'],
9
9
  expertise: []
@@ -13,7 +13,7 @@ module Natural20::RogueBuilder
13
13
  @values[:skills].each do |skill|
14
14
  q.choice t("builder.skill.#{skill}"), skill
15
15
  end
16
- q.choice t("builder.skill.thieves_tools"), 'thieves_tools'
16
+ q.choice t('builder.skill.thieves_tools'), 'thieves_tools'
17
17
  end
18
18
 
19
19
  starting_equipment = []
@@ -36,17 +36,17 @@ module Natural20::RogueBuilder
36
36
  }
37
37
  when :shortbow_and_quiver
38
38
  @class_values[:inventory] += [{
39
- type: "shortbow",
39
+ type: 'shortbow',
40
40
  qty: 1
41
41
  },
42
- {
43
- type: 'arrows',
44
- qty: 20
45
- }]
42
+ {
43
+ type: 'arrows',
44
+ qty: 20
45
+ }]
46
46
  end
47
47
  end
48
48
 
49
- shortswords = starting_equipment.select { |a| a == :shortword}.size
49
+ shortswords = starting_equipment.select { |a| a == :shortword }.size
50
50
  if shortswords > 0
51
51
  @class_values[:inventory] << {
52
52
  type: 'shortsword',
@@ -59,4 +59,4 @@ module Natural20::RogueBuilder
59
59
 
60
60
  @class_values
61
61
  end
62
- end
62
+ end
@@ -0,0 +1,77 @@
1
+ module Natural20::WizardBuilder
2
+ def wizard_builder(build_values)
3
+ @wizard_info = session.load_class('wizard')
4
+ @class_values ||= {
5
+ attributes: [],
6
+ saving_throw_proficiencies: %w[intelligence wisdom],
7
+ equipped: [],
8
+ inventory: [{
9
+ type: 'spellbook',
10
+ qty: 1
11
+ }],
12
+ prepared_spells: [],
13
+ spellbook: []
14
+ }
15
+
16
+ starting_equipment = []
17
+
18
+ starting_equipment << prompt.select(t('builder.wizard.select_starting_weapon')) do |q|
19
+ q.choice t('object.weapons.quarterstaff'), :quarterstaff
20
+ q.choice t('object.weapons.dagger'), :dagger
21
+ end
22
+
23
+ starting_equipment << prompt.select(t('builder.wizard.select_starting_weapon_2')) do |q|
24
+ q.choice t('object.component_pouch'), :component_pouch
25
+ q.choice t('object.arcane_focus'), :arcane_focus
26
+ end
27
+
28
+ starting_equipment.each do |equip|
29
+ case equip
30
+ when :quarterstaff
31
+ @class_values[:equipped] << 'quarterstaff'
32
+ when :dagger
33
+ @class_values[:equipped] << 'dagger'
34
+ when :component_pouch
35
+ @class_values[:inventory] << {
36
+ type: 'component_pouch',
37
+ qty: 1
38
+ }
39
+ when :arcane_focus
40
+ arcane_focus = prompt.select(t('builder.wizard.select_arcane_focus')) do |q|
41
+ %w[crytal orb rod staff wand].each do |equip|
42
+ q.choice t(:"object.#{equip}"), equip
43
+ end
44
+ end
45
+ @class_values[:inventory] << {
46
+ type: arcane_focus,
47
+ qty: 1
48
+ }
49
+ end
50
+ end
51
+
52
+ @class_values[:prepared_spells] = prompt.multi_select(t('builder.wizard.select_cantrip'), min: 3, max: 3) do |q|
53
+ @wizard_info.dig(:spell_list, :cantrip).each do |cantrip|
54
+ next unless session.load_spell(cantrip)
55
+
56
+ q.choice t(:"spell.#{cantrip}"), cantrip
57
+ end
58
+ end
59
+
60
+ @class_values[:spellbook] = prompt.multi_select(t('builder.wizard.select_spells'), min: 6, max: 6) do |q|
61
+ @wizard_info.dig(:spell_list, :level_1).each do |spell|
62
+ next unless session.load_spell(spell)
63
+
64
+ q.choice t(:"spell.#{spell}"), spell
65
+ end
66
+ end
67
+
68
+ prepared_spells_count = modifier_table(build_values[:ability][:int]) + 1
69
+
70
+ @class_values[:prepared_spells] = prompt.multi_select(t('builder.wizard.select_prepared_spells'), min: prepared_spells_count, max: prepared_spells_count) do |q|
71
+ @class_values[:spellbook].each do |spell|
72
+ q.choice t(:"spell.#{spell}"), spell
73
+ end
74
+ end
75
+ @class_values
76
+ end
77
+ end
@@ -1,9 +1,11 @@
1
1
  require 'natural_20/cli/builder/fighter_builder'
2
2
  require 'natural_20/cli/builder/rogue_builder'
3
+ require 'natural_20/cli/builder/wizard_builder'
3
4
  module Natural20
4
5
  class CharacterBuilder
5
6
  include Natural20::FighterBuilder
6
7
  include Natural20::RogueBuilder
8
+ include Natural20::WizardBuilder
7
9
  include Natural20::InventoryUI
8
10
 
9
11
  attr_reader :session, :battle
@@ -47,9 +49,11 @@ module Natural20
47
49
  end
48
50
  end
49
51
 
50
- @values[:description] = prompt.multiline(t('builder.description')) do |q|
51
- q.default t('buider.default_description')
52
- end.join("\n")
52
+ description = prompt.multiline(t('builder.description')) do |q|
53
+ q.default t('builder.default_description')
54
+ end
55
+
56
+ @values[:description] = description.is_a?(Array) ? description.join("\n") : description
53
57
 
54
58
  races = session.load_races
55
59
  @values[:race] = prompt.select(t('builder.select_race')) do |q|
@@ -140,7 +144,8 @@ module Natural20
140
144
 
141
145
  class_skills_selector
142
146
 
143
- send(:"#{k}_builder")
147
+ send(:"#{k}_builder", @values)
148
+
144
149
  @values.merge!(@class_values)
145
150
  @pc = Natural20::PlayerCharacter.new(session, @values)
146
151
  character_sheet(@pc)
@@ -1,10 +1,12 @@
1
1
  require 'natural_20/cli/inventory_ui'
2
2
  require 'natural_20/cli/character_builder'
3
+ require 'natural_20/cli/action_ui'
3
4
 
4
5
  class CommandlineUI < Natural20::Controller
5
6
  include Natural20::InventoryUI
6
7
  include Natural20::MovementHelper
7
8
  include Natural20::Cover
9
+ include Natural20::ActionUI
8
10
 
9
11
  TTY_PROMPT_PER_PAGE = 20
10
12
  attr_reader :battle, :map, :session, :test_mode
@@ -54,33 +56,37 @@ class CommandlineUI < Natural20::Controller
54
56
  selected_targets = []
55
57
  valid_targets = options[:targets] || battle.valid_targets_for(entity, action, target_types: options[:target_types],
56
58
  range: options[:range], filter: options[:filter])
57
- target = prompt.select("#{entity.name} targets") do |menu|
58
- valid_targets.each do |target|
59
- menu.choice target_name(entity, target, weapon: weapon_details), target
59
+ total_targets = options[:num] || 1
60
+ puts t(:"multiple_targets", total_targets: total_targets) if total_targets > 1
61
+ total_targets.times.each do |index|
62
+ target = prompt.select("Target #{index + 1}: #{entity.name} targets") do |menu|
63
+ valid_targets.each do |t|
64
+ menu.choice target_name(entity, t, weapon: weapon_details), t
65
+ end
66
+ menu.choice 'Manual - Use cursor to select a target instead', :manual
67
+ menu.choice 'Back', nil
60
68
  end
61
- menu.choice 'Manual - Use cursor to select a target instead', :manual
62
- menu.choice 'Back', nil
63
- end
64
69
 
65
- return nil if target == 'Back'
70
+ return nil if target == 'Back'
66
71
 
67
- if target == :manual
68
- valid_targets = options[:targets] || battle.valid_targets_for(entity, action,
69
- target_types: options[:target_types], range: options[:range],
70
- filter: options[:filter],
71
- include_objects: true)
72
- selected_targets = target_ui(entity, weapon: weapon_details, validation: lambda { |selected|
73
- selected_entities = map.thing_at(*selected)
72
+ if target == :manual
73
+ valid_targets = options[:targets] || battle.valid_targets_for(entity, action,
74
+ target_types: options[:target_types], range: options[:range],
75
+ filter: options[:filter],
76
+ include_objects: true)
77
+ selected_targets += target_ui(entity, weapon: weapon_details, validation: lambda { |selected|
78
+ selected_entities = map.thing_at(*selected)
74
79
 
75
- if selected_entities.empty?
76
- return false
77
- end
80
+ if selected_entities.empty?
81
+ return false
82
+ end
83
+
84
+ selected_entities.detect do |selected_entity|
85
+ valid_targets.include?(selected_entity)
86
+ end
87
+ })
88
+ end
78
89
 
79
- selected_entities.detect do |selected_entity|
80
- valid_targets.include?(selected_entity)
81
- end
82
- })
83
- else
84
90
  selected_targets << target
85
91
  end
86
92
 
@@ -184,7 +190,7 @@ class CommandlineUI < Natural20::Controller
184
190
  selected_targets = []
185
191
  targets = selected.flatten.select { |t| t.hp && t.hp.positive? }.flatten.uniq
186
192
 
187
- if targets.size > num_select
193
+ if targets.size > 1
188
194
  loop do
189
195
  target = prompt.select(t('multiple_target_prompt')) do |menu|
190
196
  targets.flatten.uniq.each do |t|
@@ -320,149 +326,34 @@ class CommandlineUI < Natural20::Controller
320
326
  end
321
327
  end
322
328
 
323
- # Show action UI
324
- # @param action [Natural20::Action]
325
- # @param entity [Entity]
326
- def action_ui(action, entity)
327
- return :stop if action == :stop
328
-
329
- cont = action.build_map
330
- loop do
331
- param = cont.param&.map do |p|
332
- case (p[:type])
333
- when :look
334
- self
335
- when :movement
336
- move_path, jump_index = move_ui(entity, p)
337
- return nil if move_path.nil?
338
-
339
- [move_path, jump_index]
340
- when :target, :select_target
341
- targets = attack_ui(entity, action, p)
342
- return nil if targets.nil? || targets.empty?
343
-
344
- targets.first
345
- when :select_weapon
346
- action.using || action.npc_action
347
- when :select_item
348
- item = prompt.select("#{entity.name} use item", per_page: TTY_PROMPT_PER_PAGE) do |menu|
349
- entity.usable_items.each do |d|
350
- if d[:consumable]
351
- menu.choice "#{d[:label].colorize(:blue)} (#{d[:qty]})", d[:name]
352
- else
353
- menu.choice d[:label].colorize(:blue).to_s, d[:name]
354
- end
355
- end
356
- menu.choice t(:back).colorize(:blue), :back
357
- end
358
-
359
- return nil if item == :back
360
-
361
- item
362
- when :select_ground_items
363
- selected_items = prompt.multi_select("Items on the ground around #{entity.name}") do |menu|
364
- map.items_on_the_ground(entity).each do |ground_item|
365
- ground, items = ground_item
366
- items.each do |t|
367
- item_label = t("object.#{t.label}", default: t.label)
368
- menu.choice t('inventory.inventory_items', name: item_label, qty: t.qty), [ground, t]
369
- end
370
- end
371
- end
372
-
373
- return nil if selected_items.empty?
374
-
375
- item_selection = {}
376
- selected_items.each do |s_item|
377
- ground, item = s_item
378
-
379
- qty = how_many?(item)
380
- item_selection[ground] ||= []
381
- item_selection[ground] << [item, qty]
382
- end
383
-
384
- item_selection.map do |k, v|
385
- [k, v]
386
- end
387
- when :select_object
388
- target_objects = entity.usable_objects(map, battle)
389
- item = prompt.select("#{entity.name} interact with") do |menu|
390
- target_objects.each do |d|
391
- menu.choice d.name.humanize.to_s, d
392
- end
393
- menu.choice t(:manual_target), :manual_target
394
- menu.choice t(:back).colorize(:blue), :back
395
- end
396
-
397
- return nil if item == :back
398
-
399
- if item == :manual_target
400
- item = target_ui(entity, num_select: 1, validation: lambda { |selected|
401
- selected_entities = map.thing_at(*selected)
402
-
403
- return false if selected_entities.empty?
404
-
405
- selected_entities.detect do |selected_entity|
406
- target_objects.include?(selected_entity)
407
- end
408
- }).first
409
- end
410
-
411
- item
412
- when :select_items
413
- selected_items = prompt.multi_select(p[:label], per_page: TTY_PROMPT_PER_PAGE) do |menu|
414
- p[:items].each do |m|
415
- item_label = t("object.#{m.label}", default: m.label)
416
- if m.try(:equipped)
417
- menu.choice t('inventory.equiped_items', name: item_label), m
418
- else
419
- menu.choice t('inventory.inventory_items', name: item_label, qty: m.qty), m
420
- end
421
- end
422
- menu.choice t(:back).colorize(:blue), :back
423
- end
329
+ def arcane_recovery_ui(entity, spell_levels)
330
+ choice = prompt.select(t('action.arcane_recovery', name: entity.name)) do |q|
331
+ spell_levels.sort.each do |level|
332
+ q.choice t(:spell_level, level: level), level
333
+ end
334
+ q.choice t('action.waive_arcane_recovery'), :waive
335
+ end
336
+ return nil if choice == :waive
424
337
 
425
- return nil if selected_items.include?(:back)
338
+ choice
339
+ end
426
340
 
427
- selected_items = selected_items.map do |m|
428
- count = how_many?(m)
429
- [m, count]
430
- end
431
- selected_items
432
- when :interact
433
- object_action = prompt.select("#{entity.name} will") do |menu|
434
- interactions = p[:target].available_interactions(entity)
435
- class_key = p[:target].class.to_s
436
- if interactions.is_a?(Array)
437
- interactions.each do |k|
438
- menu.choice t(:"object.#{class_key}.#{k}", default: k.to_s.humanize), k
439
- end
440
- else
441
- interactions.each do |k, options|
442
- label = options[:label] || t(:"object.#{class_key}.#{k}", default: k.to_s.humanize)
443
- if options[:disabled]
444
- menu.choice label, k, disabled: options[:disabled_text]
445
- else
446
- menu.choice label, k
447
- end
448
- end
449
- end
450
- menu.choice 'Back', :back
451
- end
341
+ def spell_slots_ui(entity)
342
+ puts t(:spell_slots)
343
+ (1..9).each do |level|
344
+ next unless entity.max_spell_slots(level).positive?
452
345
 
453
- return nil if item == :back
346
+ used_slots = entity.max_spell_slots(level) - entity.spell_slots(level)
347
+ used = used_slots.times.map do
348
+ '■'
349
+ end
454
350
 
455
- object_action
456
- when :show_inventory
457
- inventory_ui(entity)
458
- else
459
- raise "unknown #{p[:type]}"
460
- end
351
+ avail = entity.spell_slots(level).times.map do
352
+ '°'
461
353
  end
462
- cont = cont.next.call(*param)
463
- break if param.nil?
354
+
355
+ puts t(:spell_level_slots, level: level, slots: (used + avail).join(' '))
464
356
  end
465
- @action = cont
466
357
  end
467
358
 
468
359
  def describe_map(map, line_of_sight: [])
@@ -482,9 +373,11 @@ class CommandlineUI < Natural20::Controller
482
373
  loop do
483
374
  describe_map(battle.map, line_of_sight: entity)
484
375
  puts @renderer.render(line_of_sight: entity)
485
- puts t(:character_status_line, hp: entity.hp, max_hp: entity.max_hp, total_actions: entity.total_actions(battle), bonus_action: entity.total_bonus_actions(battle),
376
+ 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),
486
377
  available_movement: entity.available_movement(battle), statuses: entity.statuses.to_a.join(','))
487
-
378
+ entity.active_effects.each do |effect|
379
+ puts t(:effect_line, effect_name: effect[:effect].label, source: effect[:source].name)
380
+ end
488
381
  action = prompt.select(t('character_action_prompt', name: entity.name, token: entity.token&.first), per_page: TTY_PROMPT_PER_PAGE,
489
382
  filter: true) do |menu|
490
383
  entity.available_actions(@session, battle).each do |a|
@@ -1,6 +1,8 @@
1
1
  module Natural20::InventoryUI
2
2
  include Natural20::Weapons
3
3
 
4
+ # Displays the character sheet of the specified entity
5
+ # @param entity [Natural20::Entity]
4
6
  def character_sheet(entity)
5
7
  puts t('character_sheet.name', name: entity.name)
6
8
  puts t('character_sheet.level', level: entity.level)
@@ -15,6 +17,8 @@ module Natural20::InventoryUI
15
17
  puts t('character_sheet.hp', current: entity.hp, max: entity.max_hp)
16
18
  puts t('character_sheet.ac', ac: entity.armor_class)
17
19
  puts t('character_sheet.speed', speed: entity.speed)
20
+ puts t('character_sheet.proficiency_bonus', bonus: entity.proficiency_bonus)
21
+ puts t('character_sheet.spell_attack', spell_attack: entity.spell_attack_modifier) if entity.has_spells?
18
22
  puts t('character_sheet.languages')
19
23
  entity.languages.each do |lang|
20
24
  puts " #{t("language.#{lang}")}"