natural_20 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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