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.
- 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,168 @@
|
|
1
|
+
# typed: true
|
2
|
+
module Natural20
|
3
|
+
class Session
|
4
|
+
attr_reader :root_path, :game_properties, :game_time
|
5
|
+
|
6
|
+
# @param root_path [String] The current adventure working folder
|
7
|
+
# @return [Natural20::Session]
|
8
|
+
def self.new_session(root_path = nil)
|
9
|
+
@session = Natural20::Session.new(root_path)
|
10
|
+
@session
|
11
|
+
end
|
12
|
+
|
13
|
+
# @return [Natural20::Session]
|
14
|
+
def self.current_session
|
15
|
+
@session
|
16
|
+
end
|
17
|
+
|
18
|
+
def initialize(root_path = nil)
|
19
|
+
@root_path = root_path.presence || '.'
|
20
|
+
@session_state = {}
|
21
|
+
@weapons = {}
|
22
|
+
@equipment = {}
|
23
|
+
@objects = {}
|
24
|
+
@thing = {}
|
25
|
+
@char_classes = {}
|
26
|
+
@settings = {
|
27
|
+
manual_dice_roll: false
|
28
|
+
}
|
29
|
+
@game_time = 0 # game time in seconds
|
30
|
+
|
31
|
+
I18n.load_path << Dir[File.join(@root_path, 'locales') + '/*.yml']
|
32
|
+
I18n.default_locale = :en
|
33
|
+
if File.exist?(File.join(@root_path, 'game.yml'))
|
34
|
+
@game_properties = YAML.load_file(File.join(@root_path, 'game.yml')).deep_symbolize_keys!
|
35
|
+
else
|
36
|
+
raise t(:missing_game)
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
VALID_SETTINGS = %i[manual_dice_roll].freeze
|
41
|
+
|
42
|
+
# @options settings manual_dice_roll [Boolean]
|
43
|
+
def update_settings(settings = {})
|
44
|
+
settings.each_key { |k| raise 'invalid settings' unless VALID_SETTINGS.include?(k.to_sym) }
|
45
|
+
|
46
|
+
@settings.deep_merge!(settings.deep_symbolize_keys)
|
47
|
+
end
|
48
|
+
|
49
|
+
def setting(k)
|
50
|
+
raise 'invalid settings' unless VALID_SETTINGS.include?(k.to_sym)
|
51
|
+
|
52
|
+
@settings[k.to_sym]
|
53
|
+
end
|
54
|
+
|
55
|
+
def increment_game_time!(seconds = 6)
|
56
|
+
@game_time += seconds
|
57
|
+
end
|
58
|
+
|
59
|
+
def load_characters
|
60
|
+
files = Dir[File.join(@root_path, 'characters', '*.yml')]
|
61
|
+
@characters ||= files.map do |file|
|
62
|
+
YAML.load_file(file)
|
63
|
+
end
|
64
|
+
@characters.map do |char_content|
|
65
|
+
Natural20::PlayerCharacter.new(self, char_content)
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
# store a state
|
70
|
+
# @param state_type [String,Symbol]
|
71
|
+
# @param value [Hash]
|
72
|
+
def save_state(state_type, value = {})
|
73
|
+
@session_state[state_type.to_sym] ||= {}
|
74
|
+
@session_state[state_type.to_sym].deep_merge!(value)
|
75
|
+
end
|
76
|
+
|
77
|
+
def load_state(state_type)
|
78
|
+
@session_state[state_type.to_sym] || {}
|
79
|
+
end
|
80
|
+
|
81
|
+
def has_save_game?
|
82
|
+
File.exist?(File.join(@root_path, 'savegame.yml'))
|
83
|
+
end
|
84
|
+
|
85
|
+
# @param battle [Natural20::BattleMap]
|
86
|
+
def save_game(battle)
|
87
|
+
File.write(File.join(@root_path, 'savegame.yml'), battle.to_yaml)
|
88
|
+
end
|
89
|
+
|
90
|
+
def save_character(name, data)
|
91
|
+
File.write(File.join(@root_path, 'characters', "#{name}.yml"), data.to_yaml)
|
92
|
+
end
|
93
|
+
|
94
|
+
# @return [Natural20::Battle]
|
95
|
+
def load_save
|
96
|
+
YAML.load_file(File.join(@root_path, 'savegame.yml'))
|
97
|
+
end
|
98
|
+
|
99
|
+
def npc(npc_type, options = {})
|
100
|
+
Natural20::Npc.new(self, npc_type, options)
|
101
|
+
end
|
102
|
+
|
103
|
+
def load_npcs
|
104
|
+
files = Dir[File.join(@root_path, 'npcs', '*.yml')]
|
105
|
+
files.map do |fname|
|
106
|
+
npc_name = File.basename(fname, '.yml')
|
107
|
+
Natural20::Npc.new(self, npc_name, rand_life: true)
|
108
|
+
end
|
109
|
+
end
|
110
|
+
|
111
|
+
def load_races
|
112
|
+
files = Dir[File.join(@root_path, 'races', '*.yml')]
|
113
|
+
files.map do |fname|
|
114
|
+
race_name = File.basename(fname, '.yml')
|
115
|
+
[race_name, YAML.load_file(fname).deep_symbolize_keys!]
|
116
|
+
end.to_h
|
117
|
+
end
|
118
|
+
|
119
|
+
def load_classes
|
120
|
+
files = Dir[File.join(@root_path, 'char_classes', '*.yml')]
|
121
|
+
files.map do |fname|
|
122
|
+
class_name = File.basename(fname, '.yml')
|
123
|
+
[class_name, YAML.load_file(fname).deep_symbolize_keys!]
|
124
|
+
end.to_h
|
125
|
+
end
|
126
|
+
|
127
|
+
def load_class(klass)
|
128
|
+
@char_classes[klass.to_sym] ||= begin
|
129
|
+
YAML.load_file(File.join(@root_path, 'char_classes', "#{klass}.yml")).deep_symbolize_keys!
|
130
|
+
end
|
131
|
+
end
|
132
|
+
|
133
|
+
def load_weapon(weapon)
|
134
|
+
@weapons[weapon.to_sym] ||= begin
|
135
|
+
weapons = YAML.load_file(File.join(@root_path, 'items', 'weapons.yml')).deep_symbolize_keys!
|
136
|
+
weapons[weapon.to_sym]
|
137
|
+
end
|
138
|
+
end
|
139
|
+
|
140
|
+
def load_weapons
|
141
|
+
YAML.load_file(File.join(@root_path, 'items', 'weapons.yml')).deep_symbolize_keys!
|
142
|
+
end
|
143
|
+
|
144
|
+
def load_thing(item)
|
145
|
+
@thing[item.to_sym] ||= begin
|
146
|
+
load_weapon(item) || load_equipment(item) || load_object(item)
|
147
|
+
end
|
148
|
+
end
|
149
|
+
|
150
|
+
def load_equipment(item)
|
151
|
+
@equipment[item.to_sym] ||= begin
|
152
|
+
equipment = YAML.load_file(File.join(@root_path, 'items', 'equipment.yml')).deep_symbolize_keys!
|
153
|
+
equipment[item.to_sym]
|
154
|
+
end
|
155
|
+
end
|
156
|
+
|
157
|
+
def load_object(object_name)
|
158
|
+
@objects[object_name.to_sym] ||= begin
|
159
|
+
objects = YAML.load_file(File.join(@root_path, 'items', 'objects.yml')).deep_symbolize_keys!
|
160
|
+
objects[object_name.to_sym]
|
161
|
+
end
|
162
|
+
end
|
163
|
+
|
164
|
+
def t(token, options = {})
|
165
|
+
I18n.t(token, options)
|
166
|
+
end
|
167
|
+
end
|
168
|
+
end
|
@@ -0,0 +1,35 @@
|
|
1
|
+
module Natural20::Cover
|
2
|
+
# @param map [Natural20::BattleMap]
|
3
|
+
# @param source [Natural20::Entity]
|
4
|
+
# @param target [Natural20::Entity]
|
5
|
+
# @param entity_1_pos [Array<Integer,Integer>]
|
6
|
+
# @param entity_2_pos [Array<Integer,Integer>]
|
7
|
+
# @return [Integer]
|
8
|
+
def cover_calculation(map, source, target, entity_1_pos: nil, entity_2_pos: nil, naturally_stealthy: false)
|
9
|
+
source_squares = entity_1_pos ? map.entity_squares_at_pos(source, *entity_1_pos) : map.entity_squares(source)
|
10
|
+
target_squares = entity_2_pos ? map.entity_squares_at_pos(target, *entity_2_pos) : map.entity_squares(target)
|
11
|
+
source_position = map.position_of(source)
|
12
|
+
source_melee_square = source.melee_squares(map, target_position: source_position, adjacent_only: true)
|
13
|
+
|
14
|
+
source_squares.map do |source_pos|
|
15
|
+
target_squares.map do |target_pos|
|
16
|
+
cover_characteristics = map.line_of_sight?(*source_pos, *target_pos, nil, true, naturally_stealthy)
|
17
|
+
next 0 unless cover_characteristics
|
18
|
+
|
19
|
+
max_ac = 0
|
20
|
+
cover_characteristics.each do |cover|
|
21
|
+
cover_type, pos = cover
|
22
|
+
|
23
|
+
next if cover_type == :none
|
24
|
+
next if source_melee_square.include?(pos)
|
25
|
+
|
26
|
+
max_ac = [max_ac, 2].max if cover_type == :half
|
27
|
+
max_ac = [max_ac, 5].max if cover_type == :three_quarter
|
28
|
+
|
29
|
+
return 1 if cover_type.is_a?(Integer) && naturally_stealthy && (cover_type - target.size_identifier) >= 1
|
30
|
+
end
|
31
|
+
max_ac
|
32
|
+
end.min
|
33
|
+
end.min || 0
|
34
|
+
end
|
35
|
+
end
|
@@ -0,0 +1,90 @@
|
|
1
|
+
# typed: false
|
2
|
+
# class used for ray trace and path trace computations
|
3
|
+
class RayTracer
|
4
|
+
def initialize(map)
|
5
|
+
@map = map
|
6
|
+
end
|
7
|
+
|
8
|
+
def ray_trace(pos1_x, pos1_y, pos2_x, pos2_y, _max_distance)
|
9
|
+
return true if [pos1_x, pos1_y] == [pos2_x, pos2_y]
|
10
|
+
|
11
|
+
if pos2_x == pos1_x
|
12
|
+
scanner = pos2_y > pos1_y ? (pos1_y...pos2_y) : (pos2_y...pos1_y)
|
13
|
+
|
14
|
+
scanner.each_with_index do |y, index|
|
15
|
+
return false if !distance.nil? && index > distance
|
16
|
+
next if (y == pos1_y) || (y == pos2_y)
|
17
|
+
return false if @map.opaque?(pos1_x, y)
|
18
|
+
end
|
19
|
+
true
|
20
|
+
else
|
21
|
+
m = (pos2_y - pos1_y).to_f / (pos2_x - pos1_x)
|
22
|
+
scanner = pos2_x > pos1_x ? (pos1_x...pos2_x) : (pos2_x...pos1_x)
|
23
|
+
if m.zero?
|
24
|
+
|
25
|
+
scanner.each_with_index do |x, index|
|
26
|
+
return false if !distance.nil? && index > distance
|
27
|
+
next if (x == pos1_x) || (x == pos2_x)
|
28
|
+
return false if @map.opaque?(x, pos2_y)
|
29
|
+
end
|
30
|
+
|
31
|
+
true
|
32
|
+
else
|
33
|
+
|
34
|
+
b = pos1_y - m * pos1_x
|
35
|
+
step = m.abs > 1 ? 1 / m.abs : m.abs
|
36
|
+
|
37
|
+
scanner.step(step).each_with_index do |x, index|
|
38
|
+
y = (m * x + b).round
|
39
|
+
|
40
|
+
return false if !distance.nil? && index > distance
|
41
|
+
next if (x.round == pos1_x && y == pos1_y) || (x.round == pos2_x && y == pos2_y)
|
42
|
+
return false if @map.opaque?(x.round, y)
|
43
|
+
end
|
44
|
+
true
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
def line_of_sight?(pos1_x, pos1_y, pos2_x, pos2_y, distance = nil)
|
50
|
+
return true if [pos1_x, pos1_y] == [pos2_x, pos2_y]
|
51
|
+
|
52
|
+
if pos2_x == pos1_x
|
53
|
+
scanner = pos2_y > pos1_y ? (pos1_y...pos2_y) : (pos2_y...pos1_y)
|
54
|
+
|
55
|
+
scanner.each_with_index do |y, index|
|
56
|
+
return false if !distance.nil? && index > distance
|
57
|
+
next if (y == pos1_y) || (y == pos2_y)
|
58
|
+
return false if @map.opaque?(pos1_x, y)
|
59
|
+
end
|
60
|
+
true
|
61
|
+
else
|
62
|
+
m = (pos2_y - pos1_y).to_f / (pos2_x - pos1_x)
|
63
|
+
if m == 0
|
64
|
+
scanner = pos2_x > pos1_x ? (pos1_x...pos2_x) : (pos2_x...pos1_x)
|
65
|
+
|
66
|
+
scanner.each_with_index do |x, index|
|
67
|
+
return false if !distance.nil? && index > distance
|
68
|
+
next if (x == pos1_x) || (x == pos2_x)
|
69
|
+
return false if @map.opaque?(x, pos2_y)
|
70
|
+
end
|
71
|
+
|
72
|
+
true
|
73
|
+
else
|
74
|
+
scanner = pos2_x > pos1_x ? (pos1_x...pos2_x) : (pos2_x...pos1_x)
|
75
|
+
|
76
|
+
b = pos1_y - m * pos1_x
|
77
|
+
step = m.abs > 1 ? 1 / m.abs : m.abs
|
78
|
+
|
79
|
+
scanner.step(step).each_with_index do |x, index|
|
80
|
+
y = (m * x + b).round
|
81
|
+
|
82
|
+
return false if !distance.nil? && index > distance
|
83
|
+
next if (x.round == pos1_x && y == pos1_y) || (x.round == pos2_x && y == pos2_y)
|
84
|
+
return false if @map.opaque?(x.round, y)
|
85
|
+
end
|
86
|
+
true
|
87
|
+
end
|
88
|
+
end
|
89
|
+
end
|
90
|
+
end
|
@@ -0,0 +1,72 @@
|
|
1
|
+
module Natural20
|
2
|
+
class StaticLightBuilder
|
3
|
+
attr_reader :lights
|
4
|
+
|
5
|
+
# @param battlemap [Natural20::BattleMap] location of the map yml file
|
6
|
+
def initialize(battlemap)
|
7
|
+
@map = battlemap
|
8
|
+
@properties = battlemap.properties
|
9
|
+
@light_properties = @properties[:lights]
|
10
|
+
@light_map = @properties.dig(:map, :light)
|
11
|
+
@base_illumniation = @properties.dig(:map, :illumination) || 1.0
|
12
|
+
@lights = []
|
13
|
+
if @light_map && @light_properties
|
14
|
+
@light_map.each_with_index do |row, cur_y|
|
15
|
+
row.each_char.map(&:to_sym).each_with_index do |key, cur_x|
|
16
|
+
next unless @light_properties[key]
|
17
|
+
|
18
|
+
@lights << {
|
19
|
+
position: [cur_x, cur_y]
|
20
|
+
}.merge(@light_properties[key])
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
def build_map
|
27
|
+
max_x, max_y = @map.size
|
28
|
+
max_y.times.map do |y|
|
29
|
+
max_x.times.map do |x|
|
30
|
+
@lights.inject(@base_illumniation) do |intensity, light|
|
31
|
+
light_pos_x, light_pos_y = light[:position]
|
32
|
+
bright_light = light.fetch(:bright, 10) / @map.feet_per_grid
|
33
|
+
dim_light = light.fetch(:dim, 5) / @map.feet_per_grid
|
34
|
+
|
35
|
+
intensity + if @map.line_of_sight?(x, y, light_pos_x, light_pos_y, bright_light, false)
|
36
|
+
1.0
|
37
|
+
elsif @map.line_of_sight?(x, y, light_pos_x, light_pos_y, bright_light + dim_light, false)
|
38
|
+
0.5
|
39
|
+
else
|
40
|
+
0.0
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end.transpose
|
45
|
+
end
|
46
|
+
|
47
|
+
# @param pos_x [Integer]
|
48
|
+
# @parma pos_y [Integer]
|
49
|
+
# @return [Float]
|
50
|
+
def light_at(pos_x, pos_y)
|
51
|
+
(@map.entities.keys + @map.interactable_objects.keys).inject(0.0) do |intensity, entity|
|
52
|
+
next intensity if entity.light_properties.nil?
|
53
|
+
|
54
|
+
light = entity.light_properties
|
55
|
+
bright_light = light.fetch(:bright, 0.0) / @map.feet_per_grid
|
56
|
+
dim_light = light.fetch(:dim, 0.0) / @map.feet_per_grid
|
57
|
+
|
58
|
+
next intensity if (bright_light + dim_light) <= 0.0
|
59
|
+
|
60
|
+
light_pos_x, light_pos_y = @map.entity_or_object_pos(entity)
|
61
|
+
|
62
|
+
intensity + if @map.line_of_sight?(pos_x, pos_y, light_pos_x, light_pos_y, bright_light, false)
|
63
|
+
1.0
|
64
|
+
elsif @map.line_of_sight?(pos_x, pos_y, light_pos_x, light_pos_y, bright_light + dim_light, false)
|
65
|
+
0.5
|
66
|
+
else
|
67
|
+
0.0
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
@@ -0,0 +1,78 @@
|
|
1
|
+
# reusable utility methods for weapon calculations
|
2
|
+
module Natural20::Weapons
|
3
|
+
# Check all the factors that affect advantage/disadvantage in attack rolls
|
4
|
+
def target_advantage_condition(battle, source, target, weapon, source_pos: nil)
|
5
|
+
advantages, disadvantages = compute_advantages_and_disadvantages(battle, source, target, weapon,
|
6
|
+
source_pos: source_pos)
|
7
|
+
|
8
|
+
return [0, [advantages, disadvantages]] if advantages.empty? && disadvantages.empty?
|
9
|
+
return [0, [advantages, disadvantages]] if !advantages.empty? && !disadvantages.empty?
|
10
|
+
|
11
|
+
return [1, [advantages, disadvantages]] unless advantages.empty?
|
12
|
+
|
13
|
+
[-1, [advantages, disadvantages]]
|
14
|
+
end
|
15
|
+
|
16
|
+
# Compute all advantages and disadvantages
|
17
|
+
# @param battle [Natural20::Battle]
|
18
|
+
# @param source [Natural20::Entity]
|
19
|
+
# @param target [Natural20::Entity]
|
20
|
+
# @option weapon type [String]
|
21
|
+
# @return [Array]
|
22
|
+
def compute_advantages_and_disadvantages(battle, source, target, weapon, source_pos: nil)
|
23
|
+
weapon = battle.session.load_weapon(weapon) if weapon.is_a?(String) || weapon.is_a?(Symbol)
|
24
|
+
advantage = []
|
25
|
+
disadvantage = []
|
26
|
+
|
27
|
+
disadvantage << :prone if source.prone?
|
28
|
+
disadvantage << :squeezed if source.squeezed?
|
29
|
+
disadvantage << :target_dodge if target.dodge?(battle)
|
30
|
+
disadvantage << :armor_proficiency unless source.proficient_with_equipped_armor?
|
31
|
+
advantage << :squeezed if target.squeezed?
|
32
|
+
advantage << :being_helped if battle.help_with?(target)
|
33
|
+
disadvantage << :target_long_range if battle.map && battle.map.distance(source, target,
|
34
|
+
entity_1_pos: source_pos) > weapon[:range]
|
35
|
+
|
36
|
+
if weapon[:type] == 'ranged_attack' && battle.map
|
37
|
+
disadvantage << :ranged_with_enemy_in_melee if battle.enemy_in_melee_range?(source, source_pos: source_pos)
|
38
|
+
disadvantage << :target_is_prone_range if target.prone?
|
39
|
+
end
|
40
|
+
|
41
|
+
if source.class_feature?('pack_tactics') && battle.ally_within_enemey_melee_range?(source, target,
|
42
|
+
source_pos: source_pos)
|
43
|
+
advantage << :pack_tactics
|
44
|
+
end
|
45
|
+
|
46
|
+
disadvantage << :small_creature_using_heavy if weapon[:properties]&.include?('heavy') && source.size == :small
|
47
|
+
advantage << :target_is_prone if weapon[:type] == 'melee_attack' && target.prone?
|
48
|
+
|
49
|
+
advantage << :unseen_attacker if battle.map && !battle.can_see?(target, source, entity_2_pos: source_pos)
|
50
|
+
disadvantage << :invisible_attacker if battle.map && !battle.can_see?(source, target, entity_1_pos: source_pos)
|
51
|
+
[advantage, disadvantage]
|
52
|
+
end
|
53
|
+
|
54
|
+
# Calculates weapon damage roll
|
55
|
+
# @param entity [Natural20::Entity]
|
56
|
+
# @param weapon [Hash] weapon descriptor
|
57
|
+
# @param second_hand [Boolean] Second hand to be used for two weapon fighting
|
58
|
+
# @return [String]
|
59
|
+
def damage_modifier(entity, weapon, second_hand: false)
|
60
|
+
damage_mod = entity.attack_ability_mod(weapon)
|
61
|
+
|
62
|
+
damage_mod = [damage_mod, 0].min if second_hand && !entity.class_feature?('two_weapon_fighting')
|
63
|
+
|
64
|
+
# compute damage roll using versatile weapon property
|
65
|
+
damage_roll = if weapon[:properties]&.include?('versatile') && entity.used_hand_slots <= 1.0
|
66
|
+
weapon[:damage_2]
|
67
|
+
else
|
68
|
+
weapon[:damage]
|
69
|
+
end
|
70
|
+
|
71
|
+
# duelist class feature
|
72
|
+
if entity.class_feature?('dueling') && weapon[:type] == 'melee_attack' && entity.used_hand_slots(weapon_only: true) <= 1.0
|
73
|
+
damage_mod += 2
|
74
|
+
end
|
75
|
+
|
76
|
+
"#{damage_roll}+#{damage_mod}"
|
77
|
+
end
|
78
|
+
end
|