natural_20 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +14 -0
- data/.rspec +3 -0
- data/.travis.yml +6 -0
- data/CODE_OF_CONDUCT.md +74 -0
- data/Gemfile +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +99 -0
- data/Rakefile +6 -0
- data/bin/compute_lights +19 -0
- data/bin/console +19 -0
- data/bin/nat20 +135 -0
- data/bin/nat20.cmd +3 -0
- data/bin/nat20author +104 -0
- data/bin/setup +8 -0
- data/char_classes/fighter.yml +45 -0
- data/char_classes/rogue.yml +54 -0
- data/characters/halfling_rogue.yml +46 -0
- data/characters/high_elf_fighter.yml +49 -0
- data/fixtures/battle_sim.yml +58 -0
- data/fixtures/battle_sim_2.yml +30 -0
- data/fixtures/battle_sim_3.yml +26 -0
- data/fixtures/battle_sim_4.yml +26 -0
- data/fixtures/battle_sim_objects.yml +101 -0
- data/fixtures/corridors.yml +24 -0
- data/fixtures/elf_rogue.yml +39 -0
- data/fixtures/halfling_rogue.yml +41 -0
- data/fixtures/high_elf_fighter.yml +49 -0
- data/fixtures/human_fighter.yml +48 -0
- data/fixtures/path_finding_test.yml +11 -0
- data/fixtures/path_finding_test_2.yml +15 -0
- data/fixtures/path_finding_test_3.yml +26 -0
- data/fixtures/thin_walls.yml +53 -0
- data/fixtures/traps.yml +25 -0
- data/game.yml +20 -0
- data/items/equipment.yml +101 -0
- data/items/objects.yml +73 -0
- data/items/weapons.yml +297 -0
- data/lib/natural_20.rb +68 -0
- data/lib/natural_20/actions/action.rb +40 -0
- data/lib/natural_20/actions/attack_action.rb +372 -0
- data/lib/natural_20/actions/concerns/action_damage.rb +14 -0
- data/lib/natural_20/actions/dash_action.rb +46 -0
- data/lib/natural_20/actions/disengage_action.rb +53 -0
- data/lib/natural_20/actions/dodge_action.rb +45 -0
- data/lib/natural_20/actions/escape_grapple_action.rb +97 -0
- data/lib/natural_20/actions/first_aid_action.rb +109 -0
- data/lib/natural_20/actions/grapple_action.rb +185 -0
- data/lib/natural_20/actions/ground_interact_action.rb +74 -0
- data/lib/natural_20/actions/help_action.rb +56 -0
- data/lib/natural_20/actions/hide_action.rb +53 -0
- data/lib/natural_20/actions/interact_action.rb +91 -0
- data/lib/natural_20/actions/inventory_action.rb +23 -0
- data/lib/natural_20/actions/look_action.rb +63 -0
- data/lib/natural_20/actions/move_action.rb +254 -0
- data/lib/natural_20/actions/multiattack_action.rb +41 -0
- data/lib/natural_20/actions/prone_action.rb +38 -0
- data/lib/natural_20/actions/short_rest_action.rb +53 -0
- data/lib/natural_20/actions/shove_action.rb +142 -0
- data/lib/natural_20/actions/stand_action.rb +47 -0
- data/lib/natural_20/actions/use_item_action.rb +57 -0
- data/lib/natural_20/ai_controller/path_compute.rb +140 -0
- data/lib/natural_20/ai_controller/standard.rb +288 -0
- data/lib/natural_20/battle.rb +544 -0
- data/lib/natural_20/battle_map.rb +843 -0
- data/lib/natural_20/cli/builder/fighter_builder.rb +104 -0
- data/lib/natural_20/cli/builder/rogue_builder.rb +62 -0
- data/lib/natural_20/cli/character_builder.rb +210 -0
- data/lib/natural_20/cli/commandline_ui.rb +612 -0
- data/lib/natural_20/cli/inventory_ui.rb +136 -0
- data/lib/natural_20/cli/map_renderer.rb +165 -0
- data/lib/natural_20/concerns/container.rb +32 -0
- data/lib/natural_20/concerns/entity.rb +1213 -0
- data/lib/natural_20/concerns/evaluator/entity_state_evaluator.rb +59 -0
- data/lib/natural_20/concerns/fighter_actions/second_wind_action.rb +51 -0
- data/lib/natural_20/concerns/fighter_class.rb +35 -0
- data/lib/natural_20/concerns/health_flavor.rb +27 -0
- data/lib/natural_20/concerns/lootable.rb +94 -0
- data/lib/natural_20/concerns/movement_helper.rb +195 -0
- data/lib/natural_20/concerns/multiattack.rb +54 -0
- data/lib/natural_20/concerns/navigation.rb +87 -0
- data/lib/natural_20/concerns/notable.rb +37 -0
- data/lib/natural_20/concerns/rogue_class.rb +26 -0
- data/lib/natural_20/controller.rb +11 -0
- data/lib/natural_20/die_roll.rb +331 -0
- data/lib/natural_20/event_manager.rb +288 -0
- data/lib/natural_20/item_library/base_item.rb +27 -0
- data/lib/natural_20/item_library/chest.rb +230 -0
- data/lib/natural_20/item_library/door_object.rb +189 -0
- data/lib/natural_20/item_library/ground.rb +124 -0
- data/lib/natural_20/item_library/healing_potion.rb +51 -0
- data/lib/natural_20/item_library/object.rb +153 -0
- data/lib/natural_20/item_library/pit_trap.rb +69 -0
- data/lib/natural_20/item_library/stone_wall.rb +18 -0
- data/lib/natural_20/npc.rb +173 -0
- data/lib/natural_20/player_character.rb +414 -0
- data/lib/natural_20/session.rb +168 -0
- data/lib/natural_20/utils/cover.rb +35 -0
- data/lib/natural_20/utils/ray_tracer.rb +90 -0
- data/lib/natural_20/utils/static_light_builder.rb +72 -0
- data/lib/natural_20/utils/weapons.rb +78 -0
- data/lib/natural_20/version.rb +4 -0
- data/locales/en.yml +304 -0
- data/maps/game_map.yml +168 -0
- data/natural_20.gemspec +46 -0
- data/npcs/goblin.yml +64 -0
- data/npcs/human_guard.yml +48 -0
- data/npcs/ogre.yml +61 -0
- data/npcs/owlbear.yml +55 -0
- data/npcs/wolf.yml +46 -0
- data/races/elf.yml +44 -0
- data/races/halfling.yml +22 -0
- data/races/human.yml +13 -0
- 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
|