natural_20 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +14 -0
  3. data/.rspec +3 -0
  4. data/.travis.yml +6 -0
  5. data/CODE_OF_CONDUCT.md +74 -0
  6. data/Gemfile +7 -0
  7. data/LICENSE.txt +21 -0
  8. data/README.md +99 -0
  9. data/Rakefile +6 -0
  10. data/bin/compute_lights +19 -0
  11. data/bin/console +19 -0
  12. data/bin/nat20 +135 -0
  13. data/bin/nat20.cmd +3 -0
  14. data/bin/nat20author +104 -0
  15. data/bin/setup +8 -0
  16. data/char_classes/fighter.yml +45 -0
  17. data/char_classes/rogue.yml +54 -0
  18. data/characters/halfling_rogue.yml +46 -0
  19. data/characters/high_elf_fighter.yml +49 -0
  20. data/fixtures/battle_sim.yml +58 -0
  21. data/fixtures/battle_sim_2.yml +30 -0
  22. data/fixtures/battle_sim_3.yml +26 -0
  23. data/fixtures/battle_sim_4.yml +26 -0
  24. data/fixtures/battle_sim_objects.yml +101 -0
  25. data/fixtures/corridors.yml +24 -0
  26. data/fixtures/elf_rogue.yml +39 -0
  27. data/fixtures/halfling_rogue.yml +41 -0
  28. data/fixtures/high_elf_fighter.yml +49 -0
  29. data/fixtures/human_fighter.yml +48 -0
  30. data/fixtures/path_finding_test.yml +11 -0
  31. data/fixtures/path_finding_test_2.yml +15 -0
  32. data/fixtures/path_finding_test_3.yml +26 -0
  33. data/fixtures/thin_walls.yml +53 -0
  34. data/fixtures/traps.yml +25 -0
  35. data/game.yml +20 -0
  36. data/items/equipment.yml +101 -0
  37. data/items/objects.yml +73 -0
  38. data/items/weapons.yml +297 -0
  39. data/lib/natural_20.rb +68 -0
  40. data/lib/natural_20/actions/action.rb +40 -0
  41. data/lib/natural_20/actions/attack_action.rb +372 -0
  42. data/lib/natural_20/actions/concerns/action_damage.rb +14 -0
  43. data/lib/natural_20/actions/dash_action.rb +46 -0
  44. data/lib/natural_20/actions/disengage_action.rb +53 -0
  45. data/lib/natural_20/actions/dodge_action.rb +45 -0
  46. data/lib/natural_20/actions/escape_grapple_action.rb +97 -0
  47. data/lib/natural_20/actions/first_aid_action.rb +109 -0
  48. data/lib/natural_20/actions/grapple_action.rb +185 -0
  49. data/lib/natural_20/actions/ground_interact_action.rb +74 -0
  50. data/lib/natural_20/actions/help_action.rb +56 -0
  51. data/lib/natural_20/actions/hide_action.rb +53 -0
  52. data/lib/natural_20/actions/interact_action.rb +91 -0
  53. data/lib/natural_20/actions/inventory_action.rb +23 -0
  54. data/lib/natural_20/actions/look_action.rb +63 -0
  55. data/lib/natural_20/actions/move_action.rb +254 -0
  56. data/lib/natural_20/actions/multiattack_action.rb +41 -0
  57. data/lib/natural_20/actions/prone_action.rb +38 -0
  58. data/lib/natural_20/actions/short_rest_action.rb +53 -0
  59. data/lib/natural_20/actions/shove_action.rb +142 -0
  60. data/lib/natural_20/actions/stand_action.rb +47 -0
  61. data/lib/natural_20/actions/use_item_action.rb +57 -0
  62. data/lib/natural_20/ai_controller/path_compute.rb +140 -0
  63. data/lib/natural_20/ai_controller/standard.rb +288 -0
  64. data/lib/natural_20/battle.rb +544 -0
  65. data/lib/natural_20/battle_map.rb +843 -0
  66. data/lib/natural_20/cli/builder/fighter_builder.rb +104 -0
  67. data/lib/natural_20/cli/builder/rogue_builder.rb +62 -0
  68. data/lib/natural_20/cli/character_builder.rb +210 -0
  69. data/lib/natural_20/cli/commandline_ui.rb +612 -0
  70. data/lib/natural_20/cli/inventory_ui.rb +136 -0
  71. data/lib/natural_20/cli/map_renderer.rb +165 -0
  72. data/lib/natural_20/concerns/container.rb +32 -0
  73. data/lib/natural_20/concerns/entity.rb +1213 -0
  74. data/lib/natural_20/concerns/evaluator/entity_state_evaluator.rb +59 -0
  75. data/lib/natural_20/concerns/fighter_actions/second_wind_action.rb +51 -0
  76. data/lib/natural_20/concerns/fighter_class.rb +35 -0
  77. data/lib/natural_20/concerns/health_flavor.rb +27 -0
  78. data/lib/natural_20/concerns/lootable.rb +94 -0
  79. data/lib/natural_20/concerns/movement_helper.rb +195 -0
  80. data/lib/natural_20/concerns/multiattack.rb +54 -0
  81. data/lib/natural_20/concerns/navigation.rb +87 -0
  82. data/lib/natural_20/concerns/notable.rb +37 -0
  83. data/lib/natural_20/concerns/rogue_class.rb +26 -0
  84. data/lib/natural_20/controller.rb +11 -0
  85. data/lib/natural_20/die_roll.rb +331 -0
  86. data/lib/natural_20/event_manager.rb +288 -0
  87. data/lib/natural_20/item_library/base_item.rb +27 -0
  88. data/lib/natural_20/item_library/chest.rb +230 -0
  89. data/lib/natural_20/item_library/door_object.rb +189 -0
  90. data/lib/natural_20/item_library/ground.rb +124 -0
  91. data/lib/natural_20/item_library/healing_potion.rb +51 -0
  92. data/lib/natural_20/item_library/object.rb +153 -0
  93. data/lib/natural_20/item_library/pit_trap.rb +69 -0
  94. data/lib/natural_20/item_library/stone_wall.rb +18 -0
  95. data/lib/natural_20/npc.rb +173 -0
  96. data/lib/natural_20/player_character.rb +414 -0
  97. data/lib/natural_20/session.rb +168 -0
  98. data/lib/natural_20/utils/cover.rb +35 -0
  99. data/lib/natural_20/utils/ray_tracer.rb +90 -0
  100. data/lib/natural_20/utils/static_light_builder.rb +72 -0
  101. data/lib/natural_20/utils/weapons.rb +78 -0
  102. data/lib/natural_20/version.rb +4 -0
  103. data/locales/en.yml +304 -0
  104. data/maps/game_map.yml +168 -0
  105. data/natural_20.gemspec +46 -0
  106. data/npcs/goblin.yml +64 -0
  107. data/npcs/human_guard.yml +48 -0
  108. data/npcs/ogre.yml +61 -0
  109. data/npcs/owlbear.yml +55 -0
  110. data/npcs/wolf.yml +46 -0
  111. data/races/elf.yml +44 -0
  112. data/races/halfling.yml +22 -0
  113. data/races/human.yml +13 -0
  114. metadata +373 -0
@@ -0,0 +1,104 @@
1
+ module Natural20::FighterBuilder
2
+ def fighter_builder
3
+ @class_values ||= {
4
+ attributes: [],
5
+ saving_throw_proficiencies: %w[strength constitution],
6
+ equipped: [],
7
+ inventory: []
8
+ }
9
+
10
+ fighter_features = %w[archery defense dueling great_weapon_fighting protection two_weapon_fighting]
11
+ @class_values[:attributes] << prompt.select(t('builder.fighter.select_fighting_style')) do |q|
12
+ fighter_features.each do |style|
13
+ q.choice t("builder.fighter.#{style}"), style
14
+ end
15
+ end
16
+
17
+ starting_equipment = []
18
+ starting_equipment << prompt.select(t('builder.fighter.select_starting_weapon')) do |q|
19
+ q.choice t('object.weapons.chain_mail'), :chain_mail
20
+ q.choice t('object.weapons.longbow_and_arrows'), :longbow_and_arrows
21
+ end
22
+
23
+ starting_equipment << prompt.select(t('builder.fighter.select_starting_weapon_2')) do |q|
24
+ q.choice t('object.martial_weapon_and_shield'), :martial_weapon_and_shield
25
+ q.choice t('object.two_martial_weapons'), :two_martial_weapons
26
+ end
27
+
28
+ starting_equipment << prompt.select(t('builder.fighter.select_starting_weapon_3')) do |q|
29
+ q.choice t('object.light_crossbow_and_20_bolts'), :light_crossbow_and_20_bolts
30
+ q.choice t('object.two_handaxes'), :two_handaxes
31
+ end
32
+
33
+ # starting_equipment << prompt.select(t('builder.fighter.select_starting_weapon_4')) do |q|
34
+ # q.choice t('object.dungeoneers_pack'), :dungeoneers_pack
35
+ # q.choice t('object.explorers_pack'), :explorers_pack
36
+ # end
37
+
38
+ martial_weapons = session.load_weapons.map do |k, weapon|
39
+ next unless weapon[:proficiency_type]&.include?('martial')
40
+ next if weapon[:rarity] && weapon[:rarity] != 'common'
41
+
42
+ k
43
+ end.compact
44
+
45
+ starting_equipment.each do |equipment|
46
+ case equipment
47
+ when :chain_mail
48
+ @class_values[:equipped] << 'chain_mail'
49
+ when :longbow_and_arrows
50
+ @class_values[:inventory] << {
51
+ type: 'longbow',
52
+ qty: 1
53
+ }
54
+ when :martial_weapon_and_shield
55
+ chosen_martial_weapon = prompt.select(t('builder.select_martial_weapon')) do |q|
56
+ martial_weapons.each do |weapon|
57
+ q.choice t("object.weapons.#{weapon}"), weapon
58
+ end
59
+ end
60
+ @class_values[:inventory] << {
61
+ type: chosen_martial_weapon,
62
+ qty: 1
63
+ }
64
+ @class_values[:inventory] << {
65
+ type: 'shield',
66
+ qty: 1
67
+ }
68
+ when :two_martial_weapons
69
+ chosen_martial_weapons = prompt.multi_select(t('builder.select_martial_weapon'), min: 2, max: 2) do |q|
70
+ martial_weapons.each do |weapon|
71
+ q.choice t("object.weapons.#{weapon}"), weapon
72
+ end
73
+ end
74
+ chosen_martial_weapons.each do |w|
75
+ @class_values[:inventory] << {
76
+ type: w,
77
+ qty: 1
78
+ }
79
+ end
80
+ when :light_crossbow_and_20_bolts
81
+ @class_values[:inventory] << {
82
+ type: 'crossbow',
83
+ qty: 1
84
+ }
85
+ @class_values[:inventory] << {
86
+ type: 'bolts',
87
+ qty: 20
88
+ }
89
+ when :two_handaxes
90
+ @class_values[:inventory] << {
91
+ type: 'handaxe',
92
+ qty: 2
93
+ }
94
+ # when :dungeoneers_pack
95
+ # when :explorers_pack
96
+ end
97
+ end
98
+
99
+ result = Natural20::DieRoll.parse(@class_properties[:hit_die])
100
+ @values[:max_hp] = result.die_count * result.die_type.to_i + modifier_table(@values.dig(:ability, :con))
101
+
102
+ @class_values
103
+ end
104
+ end
@@ -0,0 +1,62 @@
1
+ module Natural20::RogueBuilder
2
+ def rogue_builder
3
+ @class_values ||= {
4
+ attributes: [],
5
+ saving_throw_proficiencies: %w[dexterity intelligence],
6
+ equipped: ['leather','dagger','dagger'],
7
+ inventory: [],
8
+ tools: ['thieves_tools'],
9
+ expertise: []
10
+ }
11
+
12
+ @class_values[:expertise] = prompt.multi_select(t('builder.rogue.expertise'), min: 2, max: 2) do |q|
13
+ @values[:skills].each do |skill|
14
+ q.choice t("builder.skill.#{skill}"), skill
15
+ end
16
+ q.choice t("builder.skill.thieves_tools"), 'thieves_tools'
17
+ end
18
+
19
+ starting_equipment = []
20
+ starting_equipment << prompt.select(t('builder.rogue.select_starting_weapon')) do |q|
21
+ q.choice t('object.rapier'), :rapier
22
+ q.choice t('object.shortsword'), :shortsword
23
+ end
24
+
25
+ starting_equipment << prompt.select(t('builder.rogue.select_starting_weapon_2')) do |q|
26
+ q.choice t('object.shortbow_and_quiver'), :shortbow_and_quiver
27
+ q.choice t('object.shortsword'), :shortsword
28
+ end
29
+
30
+ starting_equipment.each do |equip|
31
+ case equip
32
+ when :rapier
33
+ @class_values[:inventory] << {
34
+ qty: 1,
35
+ type: 'rapier'
36
+ }
37
+ when :shortbow_and_quiver
38
+ @class_values[:inventory] += [{
39
+ type: "shortbow",
40
+ qty: 1
41
+ },
42
+ {
43
+ type: 'arrows',
44
+ qty: 20
45
+ }]
46
+ end
47
+ end
48
+
49
+ shortswords = starting_equipment.select { |a| a == :shortword}.size
50
+ if shortswords > 0
51
+ @class_values[:inventory] << {
52
+ type: 'shortsword',
53
+ qty: shortswords
54
+ }
55
+ end
56
+
57
+ result = Natural20::DieRoll.parse(@class_properties[:hit_die])
58
+ @values[:max_hp] = result.die_count * result.die_type.to_i + modifier_table(@values.dig(:ability, :con))
59
+
60
+ @class_values
61
+ end
62
+ end
@@ -0,0 +1,210 @@
1
+ require 'natural_20/cli/builder/fighter_builder'
2
+ require 'natural_20/cli/builder/rogue_builder'
3
+ module Natural20
4
+ class CharacterBuilder
5
+ include Natural20::FighterBuilder
6
+ include Natural20::RogueBuilder
7
+ include Natural20::InventoryUI
8
+
9
+ attr_reader :session, :battle
10
+
11
+ def initialize(prompt, session, battle)
12
+ @prompt = prompt
13
+ @session = session
14
+ @battle = battle
15
+ end
16
+
17
+ def build_character
18
+ @values = {
19
+ hit_die: 'inherit',
20
+ classes: {},
21
+ ability: {},
22
+ skills: [],
23
+ level: 1,
24
+ token: ['X']
25
+ }
26
+ loop do
27
+ ability_method = :random
28
+
29
+ @values[:name] = prompt.ask(t('builder.enter_name'), default: @values[:name]) do |q|
30
+ q.required true
31
+ q.validate(/\A\w+\Z/)
32
+ q.modify :capitalize
33
+ end
34
+
35
+ @values[:token] = [prompt.ask(t('builder.token'), default: @values[:name][0])]
36
+ @values[:color] = prompt.select(t('builder.token_color')) do |q|
37
+ %i[
38
+ red light_red
39
+ green light_green
40
+ yellow light_yellow
41
+ blue light_blue
42
+ magenta light_magenta
43
+ cyan light_cyan
44
+ white light_white
45
+ ].each do |color|
46
+ q.choice @values[:token].first.colorize(color), color
47
+ end
48
+ end
49
+
50
+ @values[:description] = prompt.multiline(t('builder.description')) do |q|
51
+ q.default t('buider.default_description')
52
+ end.join("\n")
53
+
54
+ races = session.load_races
55
+ @values[:race] = prompt.select(t('builder.select_race')) do |q|
56
+ races.each do |race, details|
57
+ q.choice details[:label] || race.humanize, race
58
+ end
59
+ end
60
+
61
+ race_detail = races[@values[:race]]
62
+ if race_detail[:subrace]
63
+ @values[:subrace] = prompt.select(t('builder.select_subrace')) do |q|
64
+ race_detail[:subrace].each do |subrace, detail|
65
+ q.choice detail[:label] || t("builder.races.#{subrace}"), subrace
66
+ end
67
+ end
68
+ end
69
+ subrace_detail = race_detail.dig(:subrace, @values[:subrace]&.to_sym)
70
+
71
+ known_languages = race_detail.fetch(:languages, []) + (subrace_detail&.fetch(:languages, []) || [])
72
+ language_choice = race_detail.fetch(:language_choice, 0) + (subrace_detail&.fetch(:language_choice, 0) || 0)
73
+ if language_choice.positive?
74
+ language_selector(ALL_LANGUAGES - known_languages, min: language_choice, max: language_choice)
75
+ end
76
+
77
+ race_bonus = race_detail[:attribute_bonus] || {}
78
+ subrace_bonus = subrace_detail&.fetch(:attribute_bonus, {}) || {}
79
+
80
+ attribute_bonuses = race_bonus.merge!(subrace_bonus)
81
+
82
+ k = prompt.select(t('builder.class')) do |q|
83
+ session.load_classes.each do |klass, details|
84
+ q.choice details[:label] || klass.humanize, klass.to_sym
85
+ end
86
+ end
87
+
88
+ @values[:classes][k.to_sym] = 1
89
+ @class_properties = session.load_class(k)
90
+
91
+ ability_method = prompt.select(t('builder.ability_score_method')) do |q|
92
+ q.choice t('builder.ability_score.random'), :random
93
+ q.choice t('builder.ability_score.fixed'), :fixed
94
+ # q.choice t('builder.ability_score.point_buy'), :point_buy
95
+ end
96
+
97
+ ability_scores = if ability_method == :random
98
+ 6.times.map do |index|
99
+ r = 4.times.map do |_x|
100
+ die_roll = Natural20::DieRoll.roll('1d6', battle: battle,
101
+ description: t('dice_roll.ability_score', roll_num: index + 1))
102
+ die_roll.result
103
+ end.sort.reverse
104
+ puts "#{index + 1}. #{r.join(',')}"
105
+
106
+ r.take(3).sum
107
+ end.sort.reverse
108
+ elsif ability_method == :fixed
109
+ [15, 14, 13, 12, 10, 8]
110
+ end
111
+
112
+ puts t('builder.assign_ability_scores', scores: ability_scores.join(','))
113
+
114
+ chosen_score = []
115
+
116
+ Natural20::Entity::ATTRIBUTE_TYPES_ABBV.each do |type|
117
+ bonus = attribute_bonuses[type.to_sym] || 0
118
+ ability_choice_str = t("builder.#{type}")
119
+ ability_choice_str += " (+#{bonus})" if bonus.positive?
120
+ score_index = prompt.select(ability_choice_str) do |q|
121
+ ability_scores.each_with_index do |score, index|
122
+ next if chosen_score.include?(index)
123
+
124
+ q.choice score, index
125
+ end
126
+ end
127
+
128
+ chosen_score << score_index
129
+ @values[:ability][type.to_sym] = ability_scores[score_index] + bonus
130
+ end
131
+
132
+ class_skills_selector
133
+
134
+ send(:"#{k}_builder")
135
+ @values.merge!(@class_values)
136
+ @pc = Natural20::PlayerCharacter.new(session, @values)
137
+ character_sheet(@pc)
138
+ break if prompt.yes?(t('builder.review'))
139
+ end
140
+
141
+ session.save_character(@values[:name], @values)
142
+
143
+ @pc
144
+ end
145
+
146
+ ALL_LANGUAGES = %w[abyssal
147
+ celestial
148
+ common
149
+ deep_speech
150
+ draconic
151
+ dwarvish
152
+ elvish
153
+ giant
154
+ gnomish
155
+ goblin
156
+ halfling
157
+ infernal
158
+ orc
159
+ primordial
160
+ sylvan
161
+ undercommon]
162
+
163
+ protected
164
+
165
+ def language_selector(languages = ALL_LANGUAGES, min: 1, max: 1)
166
+ @values[:languages] = prompt.multi_select(t('builder.select_languages'), min: min, max: max, per_page: 20) do |q|
167
+ languages.each do |lang|
168
+ q.choice t("language.#{lang}"), lang
169
+ end
170
+ end
171
+ end
172
+
173
+ def class_skills_selector
174
+ class_skills = @class_properties[:available_skills]
175
+ num_choices = @class_properties[:available_skills_choices]
176
+ @values[:skills] += prompt.multi_select(t("builder.#{@values[:classes].keys.first}.select_skill"),
177
+ min: num_choices, max: num_choices, per_page: 20) do |q|
178
+ class_skills.each do |skill|
179
+ q.choice t("builder.skill.#{skill}"), skill
180
+ end
181
+ end
182
+ end
183
+
184
+ def modifier_table(value)
185
+ mod_table = [[1, 1, -5],
186
+ [2, 3, -4],
187
+ [4, 5, -3],
188
+ [6, 7, -2],
189
+ [8, 9, -1],
190
+ [10, 11, 0],
191
+ [12, 13, 1],
192
+ [14, 15, 2],
193
+ [16, 17, 3],
194
+ [18, 19, 4],
195
+ [20, 21, 5],
196
+ [22, 23, 6],
197
+ [24, 25, 7],
198
+ [26, 27, 8],
199
+ [28, 29, 9],
200
+ [30, 30, 10]]
201
+
202
+ mod_table.each do |row|
203
+ low, high, mod = row
204
+ return mod if value.between?(low, high)
205
+ end
206
+ end
207
+
208
+ attr_reader :prompt
209
+ end
210
+ end
@@ -0,0 +1,612 @@
1
+ require 'natural_20/cli/inventory_ui'
2
+ require 'natural_20/cli/character_builder'
3
+
4
+ class CommandlineUI < Natural20::Controller
5
+ include Natural20::InventoryUI
6
+ include Natural20::MovementHelper
7
+ include Natural20::Cover
8
+
9
+ TTY_PROMPT_PER_PAGE = 20
10
+ attr_reader :battle, :map, :session, :test_mode
11
+
12
+ # Creates an instance of a commandline UI helper
13
+ # @param battle [Natural20::Battle]
14
+ # @param map [Natural20::BattleMap]
15
+ def initialize(battle, map, test_mode: false)
16
+ @battle = battle
17
+ @session = battle.session
18
+ @map = map
19
+ @test_mode = test_mode
20
+ @renderer = Natural20::MapRenderer.new(@map, @battle)
21
+ end
22
+
23
+ def target_name(entity, target, weapon: nil)
24
+ cover_ac = cover_calculation(@map, entity, target)
25
+ target_labels = []
26
+ target_labels << target.name.colorize(:red)
27
+ target_labels << "(cover AC +#{cover_ac})" if cover_ac.positive?
28
+ if weapon
29
+ advantage_mod, adv_info = target_advantage_condition(battle, entity, target, weapon)
30
+ adv_details, disadv_details = adv_info
31
+ target_labels << t(:with_advantage) if advantage_mod.positive?
32
+ target_labels << t(:with_disadvantage) if advantage_mod.negative?
33
+
34
+ reasons = []
35
+ adv_details.each do |d|
36
+ reasons << "+#{t("attack_status.#{d}")}".colorize(:blue)
37
+ end
38
+ disadv_details.each do |d|
39
+ reasons << "-#{t("attack_status.#{d}")}".colorize(:red)
40
+ end
41
+
42
+ target_labels << reasons.join(',')
43
+ end
44
+ target_labels.join(' ')
45
+ end
46
+
47
+ # Create a attack target selection CLI UI
48
+ # @param entity [Natural20::Entity]
49
+ # @param action [Natural20::Action]
50
+ # @option options range [Integer]
51
+ # @options options target [Array<Natural20::Entity>] passed when there are specific valid targets
52
+ def attack_ui(entity, action, options = {})
53
+ weapon_details = options[:weapon] ? session.load_weapon(options[:weapon]) : nil
54
+ selected_targets = []
55
+ valid_targets = options[:targets] || battle.valid_targets_for(entity, action, target_types: options[:target_types],
56
+ 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
60
+ end
61
+ menu.choice 'Manual - Use cursor to select a target instead', :manual
62
+ menu.choice 'Back', nil
63
+ end
64
+
65
+ return nil if target == 'Back'
66
+
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)
74
+
75
+ if selected_entities.empty?
76
+ return false
77
+ end
78
+
79
+ selected_entities.detect do |selected_entity|
80
+ valid_targets.include?(selected_entity)
81
+ end
82
+ })
83
+ else
84
+ selected_targets << target
85
+ end
86
+
87
+ selected_targets.flatten
88
+ end
89
+
90
+ def self.clear_screen
91
+ puts "\e[H\e[2J"
92
+ end
93
+
94
+ # @param entity [Natural20::Entity]
95
+ def target_ui(entity, initial_pos: nil, num_select: 1, validation: nil, perception: 10, weapon: nil, look_mode: false)
96
+ selected = []
97
+ initial_pos ||= map.position_of(entity)
98
+ new_pos = nil
99
+ loop do
100
+ CommandlineUI.clear_screen
101
+ highlights = map.highlight(entity, perception)
102
+ prompt.say(t('perception.looking_around', perception: perception))
103
+ describe_map(battle.map, line_of_sight: entity)
104
+ puts @renderer.render(line_of_sight: entity, select_pos: initial_pos, highlight: highlights)
105
+ puts "\n"
106
+ things = map.thing_at(*initial_pos, reveal_concealed: true)
107
+
108
+ prompt.say(t('object.ground')) if things.empty?
109
+
110
+ if map.can_see_square?(entity, *initial_pos)
111
+ prompt.say(t('perception.using_darkvision')) unless map.can_see_square?(entity, *initial_pos,
112
+ allow_dark_vision: false)
113
+ things.each do |thing|
114
+ prompt.say(target_name(entity, thing, weapon: weapon)) if thing.npc?
115
+
116
+ prompt.say("#{thing.label}:")
117
+
118
+ if !@battle.can_see?(thing, entity) && thing.sentient? && thing.conscious?
119
+ prompt.say(t('perception.hide_success', label: thing.label))
120
+ end
121
+
122
+ map.perception_on(thing, entity, perception).each do |note|
123
+ prompt.say(" #{note}")
124
+ end
125
+ health_description = thing.try(:describe_health)
126
+ prompt.say(" #{health_description}") unless health_description.blank?
127
+ end
128
+
129
+ map.perception_on_area(*initial_pos, entity, perception).each do |note|
130
+ prompt.say(note)
131
+ end
132
+
133
+ prompt.say(t('perception.terrain_and_surroundings'))
134
+ terrain_adjectives = []
135
+ terrain_adjectives << 'difficult terrain' if map.difficult_terrain?(entity, *initial_pos)
136
+
137
+ intensity = map.light_at(initial_pos[0], initial_pos[1])
138
+ terrain_adjectives << if intensity >= 1.0
139
+ 'bright'
140
+ elsif intensity >= 0.5
141
+ 'dim'
142
+ else
143
+ 'dark'
144
+ end
145
+
146
+ prompt.say(" #{terrain_adjectives.join(', ')}")
147
+ else
148
+ prompt.say(t('perception.dark'))
149
+ end
150
+
151
+ movement = prompt.keypress(look_mode ? t('perception.navigation_look') : t('perception.navigation'))
152
+
153
+ if movement == 'w'
154
+ new_pos = [initial_pos[0], initial_pos[1] - 1]
155
+ elsif movement == 'a'
156
+ new_pos = [initial_pos[0] - 1, initial_pos[1]]
157
+ elsif movement == 'd'
158
+ new_pos = [initial_pos[0] + 1, initial_pos[1]]
159
+ elsif movement == 's'
160
+ new_pos = [initial_pos[0], initial_pos[1] + 1]
161
+ elsif ['x', ' ', "\r"].include? movement
162
+ next if validation && !validation.call(new_pos)
163
+
164
+ selected << initial_pos
165
+ elsif movement == 'r'
166
+ new_pos = map.position_of(entity)
167
+ next
168
+ elsif movement == "\e"
169
+ return []
170
+ else
171
+ next
172
+ end
173
+
174
+ next if new_pos.nil?
175
+ next if new_pos[0].negative? || new_pos[0] >= map.size[0] || new_pos[1].negative? || new_pos[1] >= map.size[1]
176
+ next unless map.line_of_sight_for?(entity, *new_pos)
177
+
178
+ initial_pos = new_pos
179
+
180
+ break if ['x', ' ', "\r"].include? movement
181
+ end
182
+
183
+ selected = selected.compact.map { |e| map.thing_at(*e) }
184
+ selected_targets = []
185
+ targets = selected.flatten.select { |t| t.hp && t.hp.positive? }.flatten.uniq
186
+
187
+ if targets.size > num_select
188
+ loop do
189
+ target = prompt.select(t('multiple_target_prompt')) do |menu|
190
+ targets.flatten.uniq.each do |t|
191
+ menu.choice t.name.to_s, t
192
+ end
193
+ end
194
+ selected_targets << target
195
+ break unless selected_targets.size < expected_targets
196
+ end
197
+ else
198
+ selected_targets = targets
199
+ end
200
+
201
+ return nil if selected_targets.blank?
202
+
203
+ selected_targets
204
+ end
205
+
206
+ # @param entity [Natural20::Entity]
207
+ def move_ui(entity, _options = {})
208
+ path = [map.position_of(entity)]
209
+ toggle_jump = false
210
+ jump_index = []
211
+ test_jump = []
212
+ loop do
213
+ puts "\e[H\e[2J"
214
+ movement = map.movement_cost(entity, path, battle, jump_index)
215
+ movement_cost = "#{(movement.cost * map.feet_per_grid).to_s.colorize(:green)}ft."
216
+ if entity.prone?
217
+ puts "movement (crawling) #{movement_cost}"
218
+ elsif toggle_jump && !jump_index.include?(path.size - 1)
219
+ puts "movement (ready to jump) #{movement_cost}"
220
+ elsif toggle_jump
221
+ puts "movement (jump) #{movement_cost}"
222
+ else
223
+ puts "movement #{movement_cost}"
224
+ end
225
+ describe_map(battle.map, line_of_sight: entity)
226
+ puts @renderer.render(entity: entity, line_of_sight: entity, path: path, update_on_drop: true,
227
+ acrobatics_checks: movement.acrobatics_check_locations, athletics_checks: movement.athletics_check_locations)
228
+ prompt.say('(warning) token cannot end its movement in this square') unless @map.placeable?(entity, *path.last,
229
+ battle)
230
+ prompt.say('(warning) need to perform a jump over this terrain') if @map.jump_required?(entity, *path.last)
231
+ directions = []
232
+ directions << '(wsadx) - movement, (qezc) diagonals'
233
+ directions << 'j - toggle jump' unless entity.prone?
234
+ directions << 'space/enter - confirm path'
235
+ directions << 'r - reset'
236
+ movement = prompt.keypress(directions.join(','))
237
+
238
+ if movement == 'w'
239
+ new_path = [path.last[0], path.last[1] - 1]
240
+ elsif movement == 'a'
241
+ new_path = [path.last[0] - 1, path.last[1]]
242
+ elsif movement == 'd'
243
+ new_path = [path.last[0] + 1, path.last[1]]
244
+ elsif %w[s x].include?(movement)
245
+ new_path = [path.last[0], path.last[1] + 1]
246
+ elsif [' ', "\r"].include?(movement)
247
+ next unless valid_move_path?(entity, path, battle, @map, manual_jump: jump_index)
248
+
249
+ return [path, jump_index]
250
+ elsif movement == 'q'
251
+ new_path = [path.last[0] - 1, path.last[1] - 1]
252
+ elsif movement == 'e'
253
+ new_path = [path.last[0] + 1, path.last[1] - 1]
254
+ elsif movement == 'z'
255
+ new_path = [path.last[0] - 1, path.last[1] + 1]
256
+ elsif movement == 'c'
257
+ new_path = [path.last[0] + 1, path.last[1] + 1]
258
+ elsif movement == 'r'
259
+ path = [map.position_of(entity)]
260
+ jump_index = []
261
+ toggle_jump = false
262
+ next
263
+ elsif movement == 'j' && !entity.prone?
264
+ toggle_jump = !toggle_jump
265
+ next
266
+ elsif movement == "\e"
267
+ return nil
268
+ else
269
+ next
270
+ end
271
+
272
+ next if new_path[0].negative? || new_path[0] >= map.size[0] || new_path[1].negative? || new_path[1] >= map.size[1]
273
+
274
+ test_jump = jump_index + [path.size] if toggle_jump
275
+
276
+ if path.size > 1 && new_path == path[path.size - 2]
277
+ jump_index.delete(path.size - 1)
278
+ path.pop
279
+ toggle_jump = if jump_index.include?(path.size - 1)
280
+ true
281
+ else
282
+ false
283
+ end
284
+ elsif valid_move_path?(entity, path + [new_path], battle, @map, test_placement: false, manual_jump: test_jump)
285
+ path << new_path
286
+ jump_index = test_jump
287
+ elsif valid_move_path?(entity, path + [new_path], battle, @map, test_placement: false, manual_jump: jump_index)
288
+ path << new_path
289
+ toggle_jump = false
290
+ end
291
+ end
292
+ end
293
+
294
+ def roll_for(entity, die_type, number_of_times, description, advantage: false, disadvantage: false)
295
+ return nil unless @session.setting(:manual_dice_roll)
296
+
297
+ prompt.say(t('dice_roll.prompt', description: description, name: entity.name.colorize(:green)))
298
+ number_of_times.times.map do |index|
299
+ if advantage || disadvantage
300
+ 2.times.map do |index|
301
+ prompt.ask(t("dice_roll.roll_attempt_#{advantage ? 'advantage' : 'disadvantage'}", total: number_of_times, die_type: die_type,
302
+ number: index + 1)) do |q|
303
+ q.in("1-#{die_type}")
304
+ end
305
+ end.map(&:to_i)
306
+ else
307
+ prompt.ask(t('dice_roll.roll_attempt', die_type: die_type, number: index + 1, total: number_of_times)) do |q|
308
+ q.in("1-#{die_type}")
309
+ end.to_i
310
+ end
311
+ end
312
+ end
313
+
314
+ def prompt_hit_die_roll(entity, die_types)
315
+ prompt.select(t('dice_roll.hit_die_selection', name: entity.name, hp: entity.hp, max_hp: entity.max_hp)) do |menu|
316
+ die_types.each do |t|
317
+ menu.choice "d#{t}", t
318
+ end
319
+ menu.choice t('skip_hit_die'), :skip
320
+ end
321
+ end
322
+
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
424
+
425
+ return nil if selected_items.include?(:back)
426
+
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
452
+
453
+ return nil if item == :back
454
+
455
+ object_action
456
+ when :show_inventory
457
+ inventory_ui(entity)
458
+ else
459
+ raise "unknown #{p[:type]}"
460
+ end
461
+ end
462
+ cont = cont.next.call(*param)
463
+ break if param.nil?
464
+ end
465
+ @action = cont
466
+ end
467
+
468
+ def describe_map(map, line_of_sight: [])
469
+ line_of_sight = [line_of_sight] unless line_of_sight.is_a?(Array)
470
+ pov = line_of_sight.map(&:name).join(',')
471
+ puts "Battle Map (#{map.size[0]}x#{map.size[1]}) #{map.feet_per_grid}ft per square, pov #{pov}:"
472
+ end
473
+
474
+ # Return moves by a player using the commandline UI
475
+ # @param entity [Natural20::Entity] The entity to compute moves for
476
+ # @param battle [Natural20::Battle] An instance of the current battle
477
+ # @return [Array]
478
+ def move_for(entity, battle)
479
+ puts ''
480
+ puts "#{entity.name}'s turn"
481
+ puts '==============================='
482
+ loop do
483
+ describe_map(battle.map, line_of_sight: entity)
484
+ 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),
486
+ available_movement: entity.available_movement(battle), statuses: entity.statuses.to_a.join(','))
487
+
488
+ action = prompt.select("#{entity.name} (#{entity.token&.first}) will", per_page: TTY_PROMPT_PER_PAGE,
489
+ filter: true) do |menu|
490
+ entity.available_actions(@session, battle).each do |action|
491
+ menu.choice action.label, action
492
+ end
493
+ # menu.choice 'Console (Developer Mode)', :console
494
+ menu.choice 'End'.colorize(:red), :end
495
+ end
496
+
497
+ if action == :console
498
+ prompt.say('battle - battle object')
499
+ prompt.say('entity - Current Player/NPC')
500
+ prompt.say('@map - Current map')
501
+ binding.pry
502
+ next
503
+ end
504
+
505
+ return nil if action == :end
506
+
507
+ action = action_ui(action, entity)
508
+ next if action.nil?
509
+
510
+ return action
511
+ end
512
+ end
513
+
514
+ def game_loop
515
+ Natural20::EventManager.set_context(battle, battle.current_party)
516
+
517
+ result = battle.while_active do |entity|
518
+ start_combat = false
519
+ if battle.has_controller_for?(entity)
520
+ cycles = 0
521
+ move_path = []
522
+ loop do
523
+ cycles += 1
524
+ session.save_game(battle)
525
+ action = battle.move_for(entity)
526
+ if action.nil?
527
+
528
+ unless battle.current_party.include?(entity)
529
+ describe_map(battle.map, line_of_sight: battle.current_party)
530
+ puts @renderer.render(line_of_sight: battle.current_party, path: move_path)
531
+ end
532
+ prompt.keypress(t(:end_turn, name: entity.name)) unless battle.current_party.include? entity
533
+ move_path = []
534
+ break
535
+ end
536
+
537
+ move_path += action.move_path if action.is_a?(MoveAction)
538
+
539
+ battle.action!(action)
540
+ battle.commit(action)
541
+
542
+ if battle.check_combat
543
+ start_combat = true
544
+ break
545
+ end
546
+ break if action.nil?
547
+ end
548
+ end
549
+
550
+ start_combat
551
+ end
552
+ prompt.keypress(t(:tpk)) if result == :tpk
553
+ puts '------------'
554
+ puts t(:battle_end, num: battle.round + 1)
555
+ end
556
+
557
+ # Starts a battle
558
+ # @param chosen_characters [Array]
559
+ def battle_ui(chosen_characters)
560
+ battle.map.activate_map_triggers(:on_map_entry, nil, ui_controller: self)
561
+ battle.register_players(chosen_characters, self)
562
+ chosen_characters.each do |entity|
563
+ entity.attach_handler(:opportunity_attack, self, :opportunity_attack_listener)
564
+ end
565
+ game_loop
566
+ end
567
+
568
+ def opportunity_attack_listener(battle, session, entity, map, event)
569
+ entity_x, entity_y = map.position_of(entity)
570
+ target_x, target_y = event[:position]
571
+
572
+ distance = Math.sqrt((target_x - entity_x)**2 + (target_y - entity_y)**2).ceil
573
+
574
+ possible_actions = entity.available_actions(session, battle, opportunity_attack: true).select do |s|
575
+ weapon_details = session.load_weapon(s.using)
576
+ distance <= weapon_details[:range]
577
+ end
578
+
579
+ return nil if possible_actions.blank?
580
+
581
+ action = prompt.select(t('action.opportunity_attack', name: entity.name, target: event[:target].name)) do |menu|
582
+ possible_actions.each do |a|
583
+ menu.choice a.label, a
584
+ end
585
+ menu.choice t(:waive_opportunity_attack), :waive
586
+ end
587
+
588
+ return nil if action == :waive
589
+
590
+ if action
591
+ action.target = event[:target]
592
+ action.as_reaction = true
593
+ return action
594
+ end
595
+
596
+ nil
597
+ end
598
+
599
+ def show_message(message)
600
+ puts ''
601
+ prompt.keypress(message)
602
+ end
603
+
604
+ # @return [TTY::Prompt]
605
+ def prompt
606
+ @@prompt ||= if test_mode
607
+ TTY::Prompt::Test.new
608
+ else
609
+ TTY::Prompt.new
610
+ end
611
+ end
612
+ end