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,136 @@
|
|
1
|
+
module Natural20::InventoryUI
|
2
|
+
include Natural20::Weapons
|
3
|
+
|
4
|
+
def character_sheet(entity)
|
5
|
+
puts t('character_sheet.name', name: entity.name)
|
6
|
+
puts t('character_sheet.level', level: entity.level)
|
7
|
+
puts t('character_sheet.class', name: entity.class_properties.keys.join(','))
|
8
|
+
puts ' str dex con int wis cha'
|
9
|
+
puts ' ---- ---- ---- ---- ---- ---- '
|
10
|
+
puts "|#{entity.all_ability_scores.map { |s| " #{s} " }.join('||')}|"
|
11
|
+
puts "|#{entity.all_ability_mods.map { |s| " #{s.negative? ? s : "+#{s}"} " }.join('||')}|"
|
12
|
+
puts ' ---- ---- ---- ---- ---- ----'
|
13
|
+
puts t('character_sheet.race', race: entity.race.humanize)
|
14
|
+
puts t('character_sheet.subrace', race: entity.subrace.to_s.humanize) if entity.subrace
|
15
|
+
puts t('character_sheet.hp', current: entity.hp, max: entity.max_hp)
|
16
|
+
puts t('character_sheet.ac', ac: entity.armor_class)
|
17
|
+
puts t('character_sheet.speed', speed: entity.speed)
|
18
|
+
puts t('character_sheet.languages')
|
19
|
+
entity.languages.each do |lang|
|
20
|
+
puts " #{t("language.#{lang}")}"
|
21
|
+
end
|
22
|
+
puts t('character_sheet.skills')
|
23
|
+
Natural20::Entity::ALL_SKILLS.each do |skill|
|
24
|
+
bonus_mod = entity.send("#{skill}_mod")
|
25
|
+
prefix = entity.proficient?(skill) ? '*' : ' '
|
26
|
+
puts " #{t('character_sheet.skill_mod', prefix: prefix, skill: skill.to_s.ljust(20, ' '),
|
27
|
+
bonus: bonus_mod.negative? ? bonus_mod : "+#{bonus_mod}")}"
|
28
|
+
end
|
29
|
+
unless entity.expertise.blank?
|
30
|
+
puts t('character_sheet.expertise')
|
31
|
+
entity.expertise.each do |prof|
|
32
|
+
puts " #{prof.humanize}"
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
# @param entity [Natural20::Entity]
|
38
|
+
def inventory_ui(entity)
|
39
|
+
begin
|
40
|
+
character_sheet(entity)
|
41
|
+
item_inventory_choice = prompt.select(
|
42
|
+
t('character_sheet.inventory', weight: entity.inventory_weight,
|
43
|
+
carry_capacity: entity.carry_capacity), per_page: 20
|
44
|
+
) do |menu|
|
45
|
+
entity.equipped_items.each do |m|
|
46
|
+
proficient_str = ''
|
47
|
+
if m.subtype == 'weapon' && !entity.proficient_with_weapon?(m)
|
48
|
+
proficient_str = '(not proficient)'.colorize(:red)
|
49
|
+
end
|
50
|
+
proficient_str = '(not proficient)'.colorize(:red) if %w[armor
|
51
|
+
shield].include?(m.type) && !entity.proficient_with_armor?(m.name)
|
52
|
+
menu.choice "#{m.label} (equipped) #{proficient_str}", m
|
53
|
+
end
|
54
|
+
entity.inventory.each do |m|
|
55
|
+
menu.choice "#{m.label} x #{m.qty}", m
|
56
|
+
end
|
57
|
+
menu.choice 'Back', :back
|
58
|
+
end
|
59
|
+
|
60
|
+
return nil if item_inventory_choice == :back
|
61
|
+
|
62
|
+
weapon_details = session.load_weapon(item_inventory_choice.name)
|
63
|
+
if weapon_details
|
64
|
+
puts ' '
|
65
|
+
puts '-------'
|
66
|
+
puts (weapon_details[:label] || weapon_details[:name]).colorize(:blue)
|
67
|
+
puts "Damage Stats: to Hit +#{entity.attack_roll_mod(weapon_details)}. #{damage_modifier(entity,
|
68
|
+
weapon_details)} #{weapon_details[:damage_type]} Damage"
|
69
|
+
puts "Proficiency: #{entity.proficient_with_weapon?(weapon_details) ? t(:yes) : t(:no)}"
|
70
|
+
puts 'Properties:'
|
71
|
+
weapon_details[:properties]&.each do |p|
|
72
|
+
puts " #{t("object.properties.#{p}")}"
|
73
|
+
end
|
74
|
+
puts weapon_details[:description] || ''
|
75
|
+
end
|
76
|
+
|
77
|
+
if item_inventory_choice.equipped
|
78
|
+
choice = prompt.select(item_inventory_choice.label) do |menu|
|
79
|
+
menu.choice t(:unequip), :unequip
|
80
|
+
menu.choice t(:back), :back
|
81
|
+
end
|
82
|
+
|
83
|
+
next if choice == :back
|
84
|
+
|
85
|
+
if battle.combat? && weapon_details[:type] == 'armor'
|
86
|
+
puts t('inventory.cannot_change_armor_combat')
|
87
|
+
next
|
88
|
+
end
|
89
|
+
entity.unequip(item_inventory_choice.name)
|
90
|
+
else
|
91
|
+
choice = prompt.select(item_inventory_choice.label) do |menu|
|
92
|
+
reasons = entity.check_equip(item_inventory_choice.name)
|
93
|
+
if reasons == :ok || reasons != :unequippable
|
94
|
+
if reasons == :ok
|
95
|
+
menu.choice 'Equip', :equip
|
96
|
+
else
|
97
|
+
menu.choice 'Equip', :equip, disabled: t("object.equip_problem.#{reasons}")
|
98
|
+
end
|
99
|
+
end
|
100
|
+
menu.choice t(:drop), :drop if map.ground_at(*map.entity_or_object_pos(entity))
|
101
|
+
menu.choice 'Back', :back
|
102
|
+
end
|
103
|
+
|
104
|
+
case choice
|
105
|
+
when :back
|
106
|
+
next
|
107
|
+
when :drop # drop item to the ground
|
108
|
+
qty = how_many?(item_inventory_choice)
|
109
|
+
entity.drop_items!(battle, [[item_inventory_choice, qty]])
|
110
|
+
when :equip
|
111
|
+
if battle.combat? && weapon_details[:type] == 'armor'
|
112
|
+
puts t('inventory.cannot_change_armor_combat')
|
113
|
+
next
|
114
|
+
end
|
115
|
+
entity.equip(item_inventory_choice.name)
|
116
|
+
end
|
117
|
+
end
|
118
|
+
end while true
|
119
|
+
end
|
120
|
+
|
121
|
+
def how_many?(item_inventory_choice)
|
122
|
+
if item_inventory_choice.qty > 1
|
123
|
+
item_count = item_inventory_choice.qty
|
124
|
+
m = item_inventory_choice
|
125
|
+
item_label = t("object.#{m.label}", default: m.label)
|
126
|
+
count = prompt.ask(t('inventory.how_many', item_label: item_label, item_count: item_count),
|
127
|
+
default: item_count) do |q|
|
128
|
+
q.in "1-#{item_count}"
|
129
|
+
q.messages[:range?] = '%{value} out of expected range %{in}'
|
130
|
+
end
|
131
|
+
count.to_i
|
132
|
+
else
|
133
|
+
1
|
134
|
+
end
|
135
|
+
end
|
136
|
+
end
|
@@ -0,0 +1,165 @@
|
|
1
|
+
module Natural20
|
2
|
+
class MapRenderer
|
3
|
+
DEFAULT_TOKEN_COLOR = :cyan
|
4
|
+
attr_reader :map, :battle
|
5
|
+
|
6
|
+
# @param map [Natural20::BattleMap]
|
7
|
+
def initialize(map, battle = nil)
|
8
|
+
@map = map
|
9
|
+
@battle = battle
|
10
|
+
end
|
11
|
+
|
12
|
+
# Renders the current map to a string
|
13
|
+
# @param line_of_sight [Array,Natural20::Entity] Entity or entities to consier line of sight rendering for
|
14
|
+
# @param path [Array] Array of path to render movement
|
15
|
+
# @param select_pos [Array] coordinate position to render selection cursor
|
16
|
+
# @param update_on_drop [Boolean] If true, only render line of sight if movement has been confirmed
|
17
|
+
# @return [String]
|
18
|
+
def render(entity: nil, line_of_sight: nil, path: [], acrobatics_checks: [], athletics_checks: [], select_pos: nil, update_on_drop: true, path_char: nil, highlight: {})
|
19
|
+
highlight_positions = highlight.keys.map { |entity| @map.entity_squares(entity) }.flatten(1)
|
20
|
+
|
21
|
+
base_map.transpose.each_with_index.map do |row, row_index|
|
22
|
+
row.each_with_index.map do |c, col_index|
|
23
|
+
display = render_position(c, col_index, row_index, path: path, override_path_char: path_char, entity: entity, line_of_sight: line_of_sight,
|
24
|
+
update_on_drop: update_on_drop, acrobatics_checks: acrobatics_checks,
|
25
|
+
athletics_checks: athletics_checks)
|
26
|
+
|
27
|
+
display = display.colorize(background: :red) if highlight_positions.include?([col_index, row_index])
|
28
|
+
|
29
|
+
if select_pos && select_pos == [col_index, row_index]
|
30
|
+
display.colorize(background: :white)
|
31
|
+
else
|
32
|
+
display
|
33
|
+
end
|
34
|
+
end.join
|
35
|
+
end.join("\n") + "\n"
|
36
|
+
end
|
37
|
+
|
38
|
+
private
|
39
|
+
|
40
|
+
def render_light(pos_x, pos_y)
|
41
|
+
intensity = @map.light_at(pos_x, pos_y)
|
42
|
+
if intensity >= 1.0
|
43
|
+
:light_yellow
|
44
|
+
elsif intensity >= 0.5
|
45
|
+
:light_black
|
46
|
+
else
|
47
|
+
:black
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
def object_token(pos_x, pos_y)
|
52
|
+
object_meta = @map.object_at(pos_x, pos_y)
|
53
|
+
return nil unless object_meta
|
54
|
+
|
55
|
+
m_x, m_y = @map.interactable_objects[object_meta]
|
56
|
+
color = (object_meta.color.presence || DEFAULT_TOKEN_COLOR).to_sym
|
57
|
+
|
58
|
+
return nil unless object_meta.token
|
59
|
+
|
60
|
+
if object_meta.token.is_a?(Array)
|
61
|
+
object_meta.token[pos_y - m_y][pos_x - m_x].colorize(color)
|
62
|
+
else
|
63
|
+
object_meta.token.colorize(color)
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
def npc_token(pos_x, pos_y)
|
68
|
+
entity = tokens[pos_x][pos_y]
|
69
|
+
color = (entity[:entity]&.color.presence || DEFAULT_TOKEN_COLOR).to_sym
|
70
|
+
token(entity, pos_x, pos_y).colorize(color)
|
71
|
+
end
|
72
|
+
|
73
|
+
def render_position(c, col_index, row_index, path: [], override_path_char: nil, entity: nil, line_of_sight: nil, update_on_drop: true, acrobatics_checks: [],
|
74
|
+
athletics_checks: [])
|
75
|
+
background_color = render_light(col_index, row_index)
|
76
|
+
default_ground = "\u00B7".encode('utf-8').colorize(color: DEFAULT_TOKEN_COLOR, background: background_color)
|
77
|
+
c = case c
|
78
|
+
when '.', '?'
|
79
|
+
default_ground
|
80
|
+
else
|
81
|
+
object_token(col_index, row_index)&.colorize(background: background_color) || default_ground
|
82
|
+
end
|
83
|
+
|
84
|
+
# render map layer
|
85
|
+
token = if tokens[col_index][row_index]&.fetch(:entity)&.dead?
|
86
|
+
'`'.colorize(color: DEFAULT_TOKEN_COLOR)
|
87
|
+
elsif tokens[col_index][row_index]
|
88
|
+
if any_line_of_sight?(line_of_sight, tokens[col_index][row_index][:entity])
|
89
|
+
npc_token(col_index,
|
90
|
+
row_index)
|
91
|
+
end
|
92
|
+
end
|
93
|
+
|
94
|
+
token = (token&.colorize(background: background_color) || c)
|
95
|
+
|
96
|
+
if !path.empty? && (override_path_char.nil? || path[0] != [col_index, row_index])
|
97
|
+
if path.include?([col_index, row_index])
|
98
|
+
path_char = override_path_char || token
|
99
|
+
path_color = entity && map.jump_required?(entity, col_index, row_index) ? :red : :blue
|
100
|
+
path_char = "\u2713" if athletics_checks.include?([col_index,
|
101
|
+
row_index]) || acrobatics_checks.include?([col_index,
|
102
|
+
row_index])
|
103
|
+
|
104
|
+
colored_path = path_char.colorize(color: path_color, background: :white)
|
105
|
+
colored_path = colored_path.blink if path[0] == [col_index, row_index]
|
106
|
+
return colored_path
|
107
|
+
end
|
108
|
+
return ' '.colorize(background: :black) if line_of_sight && !location_is_visible?(update_on_drop, col_index, row_index,
|
109
|
+
path)
|
110
|
+
else
|
111
|
+
has_line_of_sight = if line_of_sight.is_a?(Array)
|
112
|
+
line_of_sight.detect { |l| @map.can_see_square?(l, col_index, row_index) }
|
113
|
+
elsif line_of_sight
|
114
|
+
@map.can_see_square?(line_of_sight, col_index, row_index)
|
115
|
+
end
|
116
|
+
return ' '.colorize(background: :black) if line_of_sight && !has_line_of_sight
|
117
|
+
end
|
118
|
+
|
119
|
+
token
|
120
|
+
end
|
121
|
+
|
122
|
+
def location_is_visible?(update_on_drop, pos_x, pos_y, path)
|
123
|
+
return @map.line_of_sight?(path.last[0], path.last[1], col_index, row_index, nil, false) unless update_on_drop
|
124
|
+
|
125
|
+
@map.line_of_sight?(path.last[0], path.last[1], pos_x, pos_y,
|
126
|
+
1, false) || @map.line_of_sight?(path.first[0], path.first[1], pos_x, pos_y, nil, false)
|
127
|
+
end
|
128
|
+
|
129
|
+
def any_line_of_sight?(line_of_sight, entity)
|
130
|
+
return true if @battle.nil?
|
131
|
+
|
132
|
+
if line_of_sight.is_a?(Array)
|
133
|
+
line_of_sight.detect do |l|
|
134
|
+
@battle.can_see?(l,
|
135
|
+
entity)
|
136
|
+
end
|
137
|
+
else
|
138
|
+
@battle.can_see?(line_of_sight,
|
139
|
+
entity)
|
140
|
+
end
|
141
|
+
end
|
142
|
+
|
143
|
+
def base_map
|
144
|
+
@map.base_map
|
145
|
+
end
|
146
|
+
|
147
|
+
# Returns the character to use as token for a square
|
148
|
+
# @param entity [Natural20::Entity]
|
149
|
+
# @param pos_x [Integer]
|
150
|
+
# @param pos_y [Integer]
|
151
|
+
# @return [String]
|
152
|
+
def token(entity, pos_x, pos_y)
|
153
|
+
if entity[:entity].token
|
154
|
+
m_x, m_y = @map.entities[entity[:entity]]
|
155
|
+
entity[:entity].token[pos_y - m_y][pos_x - m_x]
|
156
|
+
else
|
157
|
+
@map.tokens[pos_x][pos_y][:token]
|
158
|
+
end
|
159
|
+
end
|
160
|
+
|
161
|
+
def tokens
|
162
|
+
@map.tokens
|
163
|
+
end
|
164
|
+
end
|
165
|
+
end
|
@@ -0,0 +1,32 @@
|
|
1
|
+
module Natural20::Container
|
2
|
+
# @param battle [Natural20::Battle]
|
3
|
+
# @param source [Natural20::Entity]
|
4
|
+
# @param target [Natural20::Entity]
|
5
|
+
# @param items [Array]
|
6
|
+
def store(battle, source, target, items)
|
7
|
+
items.each do |item_with_count|
|
8
|
+
item, qty = item_with_count
|
9
|
+
source_item = source.deduct_item(item.name, qty)
|
10
|
+
target.add_item(item.name, qty, source_item)
|
11
|
+
battle.trigger_event!(:object_received, target, item_type: item.name)
|
12
|
+
end
|
13
|
+
end
|
14
|
+
|
15
|
+
# @param battle [Natural20::Battle]
|
16
|
+
# @param source [Natural20::Entity]
|
17
|
+
# @param target [Natural20::Entity]
|
18
|
+
# @param items [Array]
|
19
|
+
def retrieve(battle, source, target, items)
|
20
|
+
items.each do |item_with_count|
|
21
|
+
item, qty = item_with_count
|
22
|
+
if item.equipped
|
23
|
+
unequip(item.name, transfer_inventory: false)
|
24
|
+
source.add_item(item.name)
|
25
|
+
else
|
26
|
+
source_item = target.deduct_item(item.name, qty)
|
27
|
+
source.add_item(item.name, qty, source_item)
|
28
|
+
battle.trigger_event!(:object_received, source, item_type: item.name)
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
@@ -0,0 +1,1213 @@
|
|
1
|
+
# typed: false
|
2
|
+
module Natural20
|
3
|
+
module Entity
|
4
|
+
include EntityStateEvaluator
|
5
|
+
|
6
|
+
attr_accessor :entity_uid, :statuses, :color, :session, :death_saves,
|
7
|
+
:death_fails, :current_hit_die, :max_hit_die
|
8
|
+
|
9
|
+
ATTRIBUTE_TYPES = %w[strength dexterity constitution intelligence wisdom charisma]
|
10
|
+
ATTRIBUTE_TYPES_ABBV = %w[str dex con int wis cha]
|
11
|
+
def label
|
12
|
+
I18n.exists?(name, :en) ? I18n.t(name) : name.humanize
|
13
|
+
end
|
14
|
+
|
15
|
+
def race
|
16
|
+
@properties[:race]
|
17
|
+
end
|
18
|
+
|
19
|
+
def all_ability_scores
|
20
|
+
%i[str dex con int wis cha].map do |att|
|
21
|
+
@ability_scores[att]
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
def all_ability_mods
|
26
|
+
%i[str dex con int wis cha].map do |att|
|
27
|
+
modifier_table(@ability_scores.fetch(att))
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
def expertise?(prof)
|
32
|
+
@properties[:expertise]&.include?(prof.to_s)
|
33
|
+
end
|
34
|
+
|
35
|
+
def heal!(amt)
|
36
|
+
return if dead?
|
37
|
+
|
38
|
+
prev_hp = @hp
|
39
|
+
@death_saves = 0
|
40
|
+
@death_fails = 0
|
41
|
+
@hp = [max_hp, @hp + amt].min
|
42
|
+
|
43
|
+
conscious!
|
44
|
+
Natural20::EventManager.received_event({ source: self, event: :heal, previous: prev_hp, new: @hp, value: amt })
|
45
|
+
end
|
46
|
+
|
47
|
+
# @option damage_params damage [Natural20::DieRoll]
|
48
|
+
# @option damage_params sneak_attack [Natural20::DieRoll]
|
49
|
+
# @param battle [Natural20::Battle]
|
50
|
+
def take_damage!(damage_params, battle = nil)
|
51
|
+
dmg = damage_params[:damage].is_a?(Natural20::DieRoll) ? damage_params[:damage].result : damage_params[:damage]
|
52
|
+
dmg += damage_params[:sneak_attack].result unless damage_params[:sneak_attack].nil?
|
53
|
+
|
54
|
+
dmg = (dmg / 2.to_f).floor if resistant_to?(damage_params[:damage_type])
|
55
|
+
@hp -= dmg
|
56
|
+
|
57
|
+
if unconscious?
|
58
|
+
@statuses.delete(:stable)
|
59
|
+
@death_fails += if damage_params[:attack_roll].nat_20?
|
60
|
+
2
|
61
|
+
else
|
62
|
+
1
|
63
|
+
end
|
64
|
+
|
65
|
+
complete = false
|
66
|
+
if @death_fails >= 3
|
67
|
+
complete = true
|
68
|
+
dead!
|
69
|
+
@death_saves = 0
|
70
|
+
@death_fails = 0
|
71
|
+
end
|
72
|
+
Natural20::EventManager.received_event({ source: self, event: :death_fail, saves: @death_saves,
|
73
|
+
fails: @death_fails, complete: complete })
|
74
|
+
end
|
75
|
+
|
76
|
+
if @hp.negative? && @hp.abs >= @properties[:max_hp]
|
77
|
+
dead!
|
78
|
+
elsif @hp <= 0
|
79
|
+
npc? ? dead! : unconscious!
|
80
|
+
end
|
81
|
+
|
82
|
+
@hp = 0 if @hp <= 0
|
83
|
+
|
84
|
+
on_take_damage(battle, damage_params) if battle
|
85
|
+
|
86
|
+
Natural20::EventManager.received_event({ source: self, event: :damage, value: dmg })
|
87
|
+
end
|
88
|
+
|
89
|
+
def resistant_to?(damage_type)
|
90
|
+
@resistances.include?(damage_type)
|
91
|
+
end
|
92
|
+
|
93
|
+
def dead!
|
94
|
+
unless dead?
|
95
|
+
Natural20::EventManager.received_event({ source: self, event: :died })
|
96
|
+
drop_grapple!
|
97
|
+
@statuses.add(:dead)
|
98
|
+
@statuses.delete(:stable)
|
99
|
+
@statuses.delete(:unconscious)
|
100
|
+
end
|
101
|
+
end
|
102
|
+
|
103
|
+
def prone!
|
104
|
+
Natural20::EventManager.received_event({ source: self, event: :prone })
|
105
|
+
@statuses.add(:prone)
|
106
|
+
end
|
107
|
+
|
108
|
+
def stand!
|
109
|
+
Natural20::EventManager.received_event({ source: self, event: :stand })
|
110
|
+
@statuses.delete(:prone)
|
111
|
+
end
|
112
|
+
|
113
|
+
def prone?
|
114
|
+
@statuses.include?(:prone)
|
115
|
+
end
|
116
|
+
|
117
|
+
def dead?
|
118
|
+
@statuses.include?(:dead)
|
119
|
+
end
|
120
|
+
|
121
|
+
def unconscious?
|
122
|
+
!dead? && @statuses.include?(:unconscious)
|
123
|
+
end
|
124
|
+
|
125
|
+
def conscious?
|
126
|
+
!dead? && !unconscious?
|
127
|
+
end
|
128
|
+
|
129
|
+
def stable?
|
130
|
+
@statuses.include?(:stable)
|
131
|
+
end
|
132
|
+
|
133
|
+
def object?
|
134
|
+
false
|
135
|
+
end
|
136
|
+
|
137
|
+
def npc?
|
138
|
+
false
|
139
|
+
end
|
140
|
+
|
141
|
+
def pc?
|
142
|
+
false
|
143
|
+
end
|
144
|
+
|
145
|
+
def sentient?
|
146
|
+
npc? || pc?
|
147
|
+
end
|
148
|
+
|
149
|
+
def conscious!
|
150
|
+
@statuses.delete(:unconscious)
|
151
|
+
@statuses.delete(:stable)
|
152
|
+
end
|
153
|
+
|
154
|
+
def stable!
|
155
|
+
@statuses.add(:stable)
|
156
|
+
@death_fails = 0
|
157
|
+
@death_saves = 0
|
158
|
+
end
|
159
|
+
|
160
|
+
# convenience method used to determine if a creature
|
161
|
+
# entered or is at melee range of another
|
162
|
+
def entered_melee?(map, entity, pos_x, pos_y)
|
163
|
+
entity_1_sq = map.entity_squares(self)
|
164
|
+
entity_2_sq = map.entity_squares_at_pos(entity, pos_x, pos_y)
|
165
|
+
|
166
|
+
entity_1_sq.each do |entity_1_pos|
|
167
|
+
entity_2_sq.each do |entity_2_pos|
|
168
|
+
cur_x, cur_y = entity_1_pos
|
169
|
+
pos_x, pos_y = entity_2_pos
|
170
|
+
|
171
|
+
distance = Math.sqrt((cur_x - pos_x)**2 + (cur_y - pos_y)**2).floor * map.feet_per_grid # one square - 5 ft
|
172
|
+
|
173
|
+
# determine melee options
|
174
|
+
return true if distance <= melee_distance
|
175
|
+
end
|
176
|
+
end
|
177
|
+
|
178
|
+
false
|
179
|
+
end
|
180
|
+
|
181
|
+
def melee_distance
|
182
|
+
0
|
183
|
+
end
|
184
|
+
|
185
|
+
# @param map [Natural20::BattleMap]
|
186
|
+
def push_from(map, pos_x, pos_y, distance = 5)
|
187
|
+
x, y = map.entity_or_object_pos(self)
|
188
|
+
effective_token_size = token_size - 1
|
189
|
+
ofs_x, ofs_y = if pos_x.between?(x, x + effective_token_size) && !pos_y.between?(y, y + effective_token_size)
|
190
|
+
[0, y - pos_y > 0 ? distance : -distance]
|
191
|
+
elsif pos_y.between?(y, y + effective_token_size) && !pos_x.between?(x, x + effective_token_size)
|
192
|
+
[x - pos_x > 0 ? distance : -distance, 0]
|
193
|
+
elsif [pos_x, pos_y] == [x - 1, y - 1]
|
194
|
+
[distance, distance]
|
195
|
+
elsif [pos_x, pos_y] == [x + effective_token_size + 1, y - 1]
|
196
|
+
[-distance, distance]
|
197
|
+
elsif [pos_x, pos_y] == [x - 1, y + effective_token_size + 1]
|
198
|
+
[distance, -distance]
|
199
|
+
elsif [pos_x, pos_y] == [x + effective_token_size + 1, y + effective_token_size + 1]
|
200
|
+
[-disance, -distance]
|
201
|
+
else
|
202
|
+
raise "invalid source position #{pos_x}, #{pos_y}"
|
203
|
+
end
|
204
|
+
# convert to squares
|
205
|
+
ofs_x /= map.feet_per_grid
|
206
|
+
ofs_y /= map.feet_per_grid
|
207
|
+
|
208
|
+
if map.placeable?(self, x + ofs_x, y + ofs_y)
|
209
|
+
[x + ofs_x, y + ofs_y]
|
210
|
+
else
|
211
|
+
nil
|
212
|
+
end
|
213
|
+
end
|
214
|
+
|
215
|
+
# @param map [Natural20::BattleMap]
|
216
|
+
# @param target_position [Array<Integer,Integer>]
|
217
|
+
# @param adjacent_only [Boolean] If false uses melee distance otherwise uses fixed 1 square away
|
218
|
+
def melee_squares(map, target_position: nil, adjacent_only: false)
|
219
|
+
result = []
|
220
|
+
step = adjacent_only ? 1 : melee_distance / map.feet_per_grid
|
221
|
+
cur_x, cur_y = target_position || map.entity_or_object_pos(self)
|
222
|
+
(-step..step).each do |x_off|
|
223
|
+
(-step..step).each do |y_off|
|
224
|
+
next if x_off.zero? && y_off.zero?
|
225
|
+
|
226
|
+
# adjust melee position based on token size
|
227
|
+
adjusted_x_off = x_off
|
228
|
+
adjusted_y_off = y_off
|
229
|
+
|
230
|
+
adjusted_x_off -= token_size - 1 if x_off.negative?
|
231
|
+
adjusted_y_off -= token_size - 1 if y_off.negative?
|
232
|
+
|
233
|
+
position = [cur_x + adjusted_x_off, cur_y + adjusted_y_off]
|
234
|
+
|
235
|
+
if position[0].negative? || position[0] >= map.size[0] || position[1].negative? || position[1] >= map.size[1]
|
236
|
+
next
|
237
|
+
end
|
238
|
+
|
239
|
+
result << position
|
240
|
+
end
|
241
|
+
end
|
242
|
+
result
|
243
|
+
end
|
244
|
+
|
245
|
+
def locate_melee_positions(map, target_position, battle = nil)
|
246
|
+
result = []
|
247
|
+
step = melee_distance / map.feet_per_grid
|
248
|
+
cur_x, cur_y = target_position
|
249
|
+
(-step..step).each do |x_off|
|
250
|
+
(-step..step).each do |y_off|
|
251
|
+
next if x_off.zero? && y_off.zero?
|
252
|
+
|
253
|
+
# adjust melee position based on token size
|
254
|
+
adjusted_x_off = x_off
|
255
|
+
adjusted_y_off = y_off
|
256
|
+
|
257
|
+
adjusted_x_off -= token_size - 1 if x_off < 0
|
258
|
+
adjusted_y_off -= token_size - 1 if y_off < 0
|
259
|
+
|
260
|
+
position = [cur_x + adjusted_x_off, cur_y + adjusted_y_off]
|
261
|
+
|
262
|
+
if position[0].negative? || position[0] >= map.size[0] || position[1].negative? || position[1] >= map.size[1]
|
263
|
+
next
|
264
|
+
end
|
265
|
+
next unless map.placeable?(self, *position, battle)
|
266
|
+
|
267
|
+
result << position
|
268
|
+
end
|
269
|
+
end
|
270
|
+
result
|
271
|
+
end
|
272
|
+
|
273
|
+
def languages
|
274
|
+
@properties[:languages] || []
|
275
|
+
end
|
276
|
+
|
277
|
+
def darkvision?(distance)
|
278
|
+
return false unless @properties[:darkvision]
|
279
|
+
return false if @properties[:darkvision] < distance
|
280
|
+
|
281
|
+
true
|
282
|
+
end
|
283
|
+
|
284
|
+
def unconscious!
|
285
|
+
return if unconscious? || dead?
|
286
|
+
|
287
|
+
drop_grapple!
|
288
|
+
Natural20::EventManager.received_event({ source: self, event: :unconscious })
|
289
|
+
@statuses.add(:unconscious)
|
290
|
+
end
|
291
|
+
|
292
|
+
def description
|
293
|
+
@properties[:description].presence || ''
|
294
|
+
end
|
295
|
+
|
296
|
+
def initiative!(battle = nil)
|
297
|
+
roll = Natural20::DieRoll.roll("1d20+#{dex_mod}", description: t('dice_roll.initiative'), entity: self,
|
298
|
+
battle: battle)
|
299
|
+
value = roll.result.to_f + @ability_scores.fetch(:dex) / 100.to_f
|
300
|
+
Natural20::EventManager.received_event({ source: self, event: :initiative, roll: roll, value: value })
|
301
|
+
value
|
302
|
+
end
|
303
|
+
|
304
|
+
# Perform a death saving throw
|
305
|
+
def death_saving_throw!(battle = nil)
|
306
|
+
roll = Natural20::DieRoll.roll('1d20', description: t('dice_roll.death_saving_throw'), entity: self,
|
307
|
+
battle: battle)
|
308
|
+
if roll.nat_20?
|
309
|
+
conscious!
|
310
|
+
heal!(1)
|
311
|
+
|
312
|
+
Natural20::EventManager.received_event({ source: self, event: :death_save, roll: roll, saves: @death_saves,
|
313
|
+
fails: death_fails, complete: true, stable: true, success: true })
|
314
|
+
elsif roll.result >= 10
|
315
|
+
@death_saves += 1
|
316
|
+
complete = false
|
317
|
+
|
318
|
+
if @death_saves >= 3
|
319
|
+
complete = true
|
320
|
+
@death_saves = 0
|
321
|
+
@death_fails = 0
|
322
|
+
stable!
|
323
|
+
end
|
324
|
+
Natural20::EventManager.received_event({ source: self, event: :death_save, roll: roll, saves: @death_saves,
|
325
|
+
fails: @death_fails, complete: complete, stable: complete })
|
326
|
+
else
|
327
|
+
@death_fails += roll.nat_1? ? 2 : 1
|
328
|
+
complete = false
|
329
|
+
if @death_fails >= 3
|
330
|
+
complete = true
|
331
|
+
dead!
|
332
|
+
@death_saves = 0
|
333
|
+
@death_fails = 0
|
334
|
+
end
|
335
|
+
|
336
|
+
Natural20::EventManager.received_event({ source: self, event: :death_fail, roll: roll, saves: @death_saves,
|
337
|
+
fails: @death_fails, complete: complete })
|
338
|
+
end
|
339
|
+
end
|
340
|
+
|
341
|
+
# @param battle [Natural20::Battle]
|
342
|
+
# @return [Hash]
|
343
|
+
def reset_turn!(battle)
|
344
|
+
entity_state = battle.entity_state_for(self)
|
345
|
+
entity_state.merge!({
|
346
|
+
action: 1,
|
347
|
+
bonus_action: 1,
|
348
|
+
reaction: 1,
|
349
|
+
movement: speed,
|
350
|
+
free_object_interaction: 1,
|
351
|
+
active_perception: 0,
|
352
|
+
active_perception_disadvantage: 0,
|
353
|
+
two_weapon: nil
|
354
|
+
})
|
355
|
+
entity_state[:statuses].delete(:dodge)
|
356
|
+
entity_state[:statuses].delete(:disengage)
|
357
|
+
battle.dismiss_help_actions_for(self)
|
358
|
+
|
359
|
+
entity_state
|
360
|
+
end
|
361
|
+
|
362
|
+
# @param grappler [Natural20::Entity]
|
363
|
+
def grappled_by!(grappler)
|
364
|
+
@statuses.add(:grappled)
|
365
|
+
@grapples ||= []
|
366
|
+
@grapples << grappler
|
367
|
+
grappler.grappling(self)
|
368
|
+
end
|
369
|
+
|
370
|
+
def escape_grapple_from!(grappler)
|
371
|
+
@grapples ||= []
|
372
|
+
@grapples.delete(grappler)
|
373
|
+
@statuses.delete(:grappled) if @grapples.empty?
|
374
|
+
grappler.ungrapple(self)
|
375
|
+
end
|
376
|
+
|
377
|
+
def grapples
|
378
|
+
@grapples || []
|
379
|
+
end
|
380
|
+
|
381
|
+
def dodging!(battle)
|
382
|
+
entity_state = battle.entity_state_for(self)
|
383
|
+
entity_state[:statuses].add(:dodge)
|
384
|
+
end
|
385
|
+
|
386
|
+
# @param battle [Natural20::Battle]
|
387
|
+
# @param stealth [Integer]
|
388
|
+
def hiding!(battle, stealth)
|
389
|
+
entity_state = battle.entity_state_for(self)
|
390
|
+
entity_state[:statuses].add(:hiding)
|
391
|
+
entity_state[:stealth] = stealth
|
392
|
+
end
|
393
|
+
|
394
|
+
def break_stealth!(battle)
|
395
|
+
entity_state = battle.entity_state_for(self)
|
396
|
+
entity_state[:statuses].delete(:hiding)
|
397
|
+
entity_state[:stealth] = 0
|
398
|
+
end
|
399
|
+
|
400
|
+
def disengage!(battle)
|
401
|
+
entity_state = battle.entity_state_for(self)
|
402
|
+
entity_state[:statuses].add(:disengage)
|
403
|
+
end
|
404
|
+
|
405
|
+
def disengage?(battle)
|
406
|
+
entity_state = battle.entity_state_for(self)
|
407
|
+
entity_state[:statuses]&.include?(:disengage)
|
408
|
+
end
|
409
|
+
|
410
|
+
def dodge?(battle)
|
411
|
+
entity_state = battle.entity_state_for(self)
|
412
|
+
return false unless entity_state
|
413
|
+
|
414
|
+
entity_state[:statuses]&.include?(:dodge)
|
415
|
+
end
|
416
|
+
|
417
|
+
def hiding?(battle)
|
418
|
+
entity_state = battle.entity_state_for(self)
|
419
|
+
return false unless entity_state
|
420
|
+
|
421
|
+
entity_state[:statuses]&.include?(:hiding)
|
422
|
+
end
|
423
|
+
|
424
|
+
def help?(battle, target)
|
425
|
+
entity_state = battle.entity_state_for(target)
|
426
|
+
return entity_state[:target_effect][self] == :help if entity_state[:target_effect]&.key?(self)
|
427
|
+
|
428
|
+
false
|
429
|
+
end
|
430
|
+
|
431
|
+
# check if current entity can see target at a certain location
|
432
|
+
def can_see?(cur_pos_x, cur_pos_y, _target_entity, pos_x, pos_y, battle)
|
433
|
+
battle.map.line_of_sight?(cur_pos_x, cur_pos_y, pos_x, pos_y)
|
434
|
+
|
435
|
+
# TODO, check invisiblity etc, range
|
436
|
+
true
|
437
|
+
end
|
438
|
+
|
439
|
+
def help!(battle, target)
|
440
|
+
target_state = battle.entity_state_for(target)
|
441
|
+
target_state[:target_effect][self] = if battle.opposing?(self, target)
|
442
|
+
:help
|
443
|
+
else
|
444
|
+
:help_ability_check
|
445
|
+
end
|
446
|
+
end
|
447
|
+
|
448
|
+
# Checks if an entity still has an action available
|
449
|
+
# @param battle [Natural20::Battle]
|
450
|
+
def action?(battle = nil)
|
451
|
+
return true if battle.nil?
|
452
|
+
|
453
|
+
(battle.entity_state_for(self)[:action].presence || 0).positive?
|
454
|
+
end
|
455
|
+
|
456
|
+
def total_actions(battle)
|
457
|
+
battle.entity_state_for(self)[:action]
|
458
|
+
end
|
459
|
+
|
460
|
+
def total_reactions(battle)
|
461
|
+
battle.entity_state_for(self)[:reaction]
|
462
|
+
end
|
463
|
+
|
464
|
+
def free_object_interaction?(battle)
|
465
|
+
return true unless battle
|
466
|
+
|
467
|
+
(battle.entity_state_for(self)[:free_object_interaction].presence || 0).positive?
|
468
|
+
end
|
469
|
+
|
470
|
+
def total_bonus_actions(battle)
|
471
|
+
battle.entity_state_for(self)[:bonus_action]
|
472
|
+
end
|
473
|
+
|
474
|
+
# @param battle [Natural::20]
|
475
|
+
# @return [Integer]
|
476
|
+
def available_movement(battle)
|
477
|
+
grappled? ? 0 : battle.entity_state_for(self)[:movement]
|
478
|
+
end
|
479
|
+
|
480
|
+
def speed
|
481
|
+
@properties[:speed]
|
482
|
+
end
|
483
|
+
|
484
|
+
def has_reaction?(battle)
|
485
|
+
(battle.entity_state_for(self)[:reaction].presence || 0).positive?
|
486
|
+
end
|
487
|
+
|
488
|
+
def str_mod
|
489
|
+
modifier_table(@ability_scores.fetch(:str))
|
490
|
+
end
|
491
|
+
|
492
|
+
def con_mod
|
493
|
+
modifier_table(@ability_scores.fetch(:con))
|
494
|
+
end
|
495
|
+
|
496
|
+
def wis_mod
|
497
|
+
modifier_table(@ability_scores.fetch(:wis))
|
498
|
+
end
|
499
|
+
|
500
|
+
def cha_mod
|
501
|
+
modifier_table(@ability_scores.fetch(:cha))
|
502
|
+
end
|
503
|
+
|
504
|
+
def int_mod
|
505
|
+
modifier_table(@ability_scores.fetch(:int))
|
506
|
+
end
|
507
|
+
|
508
|
+
def dex_mod
|
509
|
+
modifier_table(@ability_scores.fetch(:dex))
|
510
|
+
end
|
511
|
+
|
512
|
+
def ability_mod(type)
|
513
|
+
mod_type = case type.to_sym
|
514
|
+
when :wisdom, :wis
|
515
|
+
:wis
|
516
|
+
when :dexterity, :dex
|
517
|
+
:dex
|
518
|
+
when :constitution, :con
|
519
|
+
:con
|
520
|
+
when :intelligence, :int
|
521
|
+
:int
|
522
|
+
when :charisma, :cha
|
523
|
+
:cha
|
524
|
+
when :strength, :str
|
525
|
+
:str
|
526
|
+
end
|
527
|
+
modifier_table(@ability_scores.fetch(mod_type))
|
528
|
+
end
|
529
|
+
|
530
|
+
def passive_perception
|
531
|
+
@properties[:passive_perception] || 10 + wis_mod
|
532
|
+
end
|
533
|
+
|
534
|
+
def standing_jump_distance
|
535
|
+
(@ability_scores.fetch(:str) / 2).floor
|
536
|
+
end
|
537
|
+
|
538
|
+
def long_jump_distance
|
539
|
+
@ability_scores.fetch(:str)
|
540
|
+
end
|
541
|
+
|
542
|
+
def expertise
|
543
|
+
@properties.fetch(:expertise, [])
|
544
|
+
end
|
545
|
+
|
546
|
+
def token_size
|
547
|
+
square_size = size.to_sym
|
548
|
+
case square_size
|
549
|
+
when :small
|
550
|
+
1
|
551
|
+
when :medium
|
552
|
+
1
|
553
|
+
when :large
|
554
|
+
2
|
555
|
+
when :huge
|
556
|
+
3
|
557
|
+
else
|
558
|
+
raise "invalid size #{square_size}"
|
559
|
+
end
|
560
|
+
end
|
561
|
+
|
562
|
+
def size_identifier
|
563
|
+
square_size = size.to_sym
|
564
|
+
case square_size
|
565
|
+
when :small
|
566
|
+
1
|
567
|
+
when :medium
|
568
|
+
2
|
569
|
+
when :large
|
570
|
+
3
|
571
|
+
when :huge
|
572
|
+
4
|
573
|
+
when :gargantuan
|
574
|
+
5
|
575
|
+
else
|
576
|
+
raise "invalid size #{square_size}"
|
577
|
+
end
|
578
|
+
end
|
579
|
+
|
580
|
+
ALL_SKILLS = %i[acrobatics animal_handling arcana athletics deception history insight intimidation
|
581
|
+
investigation medicine nature perception performance persuasion religion sleight_of_hand stealth survival]
|
582
|
+
SKILL_AND_ABILITY_MAP = {
|
583
|
+
dex: %i[acrobatics sleight_of_hand stealth],
|
584
|
+
wis: %i[animal_handling insight medicine perception survival],
|
585
|
+
int: %i[arcana history investigation nature religion],
|
586
|
+
con: [],
|
587
|
+
str: [:athletics],
|
588
|
+
cha: %i[deception intimidation performance persuasion]
|
589
|
+
}
|
590
|
+
|
591
|
+
SKILL_AND_ABILITY_MAP.each do |ability, skills|
|
592
|
+
skills.each do |skill|
|
593
|
+
define_method("#{skill}_mod") do
|
594
|
+
ability_mod = case ability.to_sym
|
595
|
+
when :dex
|
596
|
+
dex_mod
|
597
|
+
when :wis
|
598
|
+
wis_mod
|
599
|
+
when :cha
|
600
|
+
cha_mod
|
601
|
+
when :con
|
602
|
+
con_mod
|
603
|
+
when :str
|
604
|
+
str_mod
|
605
|
+
when :int
|
606
|
+
int_mod
|
607
|
+
end
|
608
|
+
bonus = if send(:proficient?, skill)
|
609
|
+
expertise?(skill) ? proficiency_bonus * 2 : proficiency_bonus
|
610
|
+
else
|
611
|
+
0
|
612
|
+
end
|
613
|
+
ability_mod + bonus
|
614
|
+
end
|
615
|
+
|
616
|
+
define_method("#{skill}_check!") do |battle = nil, opts = {}|
|
617
|
+
modifiers = send(:"#{skill}_mod")
|
618
|
+
DieRoll.roll_with_lucky(self, "1d20+#{modifiers}", description: opts.fetch(:description, t("dice_roll.#{skill}")),
|
619
|
+
battle: battle)
|
620
|
+
end
|
621
|
+
end
|
622
|
+
end
|
623
|
+
|
624
|
+
def dexterity_check!(bonus = 0, battle: nil, description: nil)
|
625
|
+
disadvantage = !proficient_with_equipped_armor? ? true : false
|
626
|
+
DieRoll.roll_with_lucky(self, "1d20+#{dex_mod + bonus}", disadvantage: disadvantage, description: description || t('dice_roll.dexterity'),
|
627
|
+
battle: battle)
|
628
|
+
end
|
629
|
+
|
630
|
+
def strength_check!(bonus = 0, battle: nil, description: nil)
|
631
|
+
disadvantage = !proficient_with_equipped_armor? ? true : false
|
632
|
+
DieRoll.roll_with_lucky(self, "1d20+#{str_mod + bonus}", disadvantage: disadvantage, description: description || t('dice_roll.stength_check'),
|
633
|
+
battle: battle)
|
634
|
+
end
|
635
|
+
|
636
|
+
def wisdom_check!(bonus = 0, battle: nil, description: nil)
|
637
|
+
DieRoll.roll_with_lucky(self, "1d20+#{wis_mod + bonus}", description: description || t('dice_roll.wisdom_check'),
|
638
|
+
battle: battle)
|
639
|
+
end
|
640
|
+
|
641
|
+
def medicine_check!(battle = nil, description: nil)
|
642
|
+
wisdom_check!(medicine_proficient? ? proficiency_bonus : 0, battle: battle,
|
643
|
+
description: description || t('dice_roll.medicine'))
|
644
|
+
end
|
645
|
+
|
646
|
+
def attach_handler(event_name, object, callback)
|
647
|
+
@event_handlers ||= {}
|
648
|
+
@event_handlers[event_name.to_sym] = [object, callback]
|
649
|
+
end
|
650
|
+
|
651
|
+
def trigger_event(event_name, battle, session, map, event)
|
652
|
+
@event_handlers ||= {}
|
653
|
+
return unless @event_handlers.key?(event_name.to_sym)
|
654
|
+
|
655
|
+
object, method_name = @event_handlers[event_name.to_sym]
|
656
|
+
object.send(method_name.to_sym, battle, session, self, map, event)
|
657
|
+
end
|
658
|
+
|
659
|
+
# @param target [Natural20::Entity]
|
660
|
+
def grappling(target)
|
661
|
+
@grappling ||= []
|
662
|
+
@grappling << target
|
663
|
+
end
|
664
|
+
|
665
|
+
def grappling?
|
666
|
+
@grappling ||= []
|
667
|
+
|
668
|
+
!@grappling.empty?
|
669
|
+
end
|
670
|
+
|
671
|
+
def grappling_targets
|
672
|
+
@grappling ||= []
|
673
|
+
@grappling
|
674
|
+
end
|
675
|
+
|
676
|
+
# @param target [Natural20::Entity]
|
677
|
+
def ungrapple(target)
|
678
|
+
@grappling ||= []
|
679
|
+
@grappling.delete(target)
|
680
|
+
target.grapples.delete(self)
|
681
|
+
target.statuses.delete(:grappled) if target.grapples.empty?
|
682
|
+
end
|
683
|
+
|
684
|
+
def drop_grapple!
|
685
|
+
@grappling ||= []
|
686
|
+
@grappling.each do |target|
|
687
|
+
ungrapple(target)
|
688
|
+
end
|
689
|
+
end
|
690
|
+
|
691
|
+
# Removes Item from inventory
|
692
|
+
# @param ammo_type [Symbol,String]
|
693
|
+
# @param amount [Integer]
|
694
|
+
# @return [OpenStruct]
|
695
|
+
def deduct_item(ammo_type, amount = 1)
|
696
|
+
return if @inventory[ammo_type.to_sym].nil?
|
697
|
+
|
698
|
+
qty = @inventory[ammo_type.to_sym].qty
|
699
|
+
@inventory[ammo_type.to_sym].qty = [qty - amount, 0].max
|
700
|
+
|
701
|
+
@inventory[ammo_type.to_sym]
|
702
|
+
end
|
703
|
+
|
704
|
+
# Adds an item to your inventory
|
705
|
+
# @param ammo_type [Symbol,String]
|
706
|
+
# @param amount [Integer]
|
707
|
+
# @param source_item [Object]
|
708
|
+
def add_item(ammo_type, amount = 1, source_item = nil)
|
709
|
+
if @inventory[ammo_type.to_sym].nil?
|
710
|
+
@inventory[ammo_type.to_sym] =
|
711
|
+
OpenStruct.new(qty: 0, type: source_item&.type || ammo_type.to_sym)
|
712
|
+
end
|
713
|
+
|
714
|
+
qty = @inventory[ammo_type.to_sym].qty
|
715
|
+
@inventory[ammo_type.to_sym].qty = qty + amount
|
716
|
+
end
|
717
|
+
|
718
|
+
# Retrieves the item count of an item in the entities inventory
|
719
|
+
# @param inventory_type [Symbol]
|
720
|
+
# @return [Integer]
|
721
|
+
def item_count(inventory_type)
|
722
|
+
return 0 if @inventory[inventory_type.to_sym].nil?
|
723
|
+
|
724
|
+
@inventory[inventory_type.to_sym][:qty]
|
725
|
+
end
|
726
|
+
|
727
|
+
def usable_items
|
728
|
+
@inventory.map do |k, v|
|
729
|
+
item_details =
|
730
|
+
session.load_equipment(v.type)
|
731
|
+
|
732
|
+
next unless item_details
|
733
|
+
next unless item_details[:usable]
|
734
|
+
next if item_details[:consumable] && v.qty.zero?
|
735
|
+
|
736
|
+
{ name: k.to_s, label: item_details[:name] || k, item: item_details, qty: v.qty,
|
737
|
+
consumable: item_details[:consumable] }
|
738
|
+
end.compact
|
739
|
+
end
|
740
|
+
|
741
|
+
# @param battle [Natural20::Batle
|
742
|
+
# @param item_and_counts [Array<Array<OpenStruct,Integer>>]
|
743
|
+
def drop_items!(battle, item_and_counts = [])
|
744
|
+
ground = battle.map.ground_at(*battle.map.entity_or_object_pos(self))
|
745
|
+
ground&.store(battle, self, ground, item_and_counts)
|
746
|
+
end
|
747
|
+
|
748
|
+
# Show usable objects near the entity
|
749
|
+
# @param map [Natural20::BattleMap]
|
750
|
+
# @param battle [Natural20::Battle]
|
751
|
+
# @return [Array]
|
752
|
+
def usable_objects(map, battle)
|
753
|
+
map.objects_near(self, battle)
|
754
|
+
end
|
755
|
+
|
756
|
+
# Returns items in the "backpack" of the entity
|
757
|
+
# @return [Array]
|
758
|
+
def inventory
|
759
|
+
@inventory.map do |k, v|
|
760
|
+
item = @session.load_thing k
|
761
|
+
raise "unable to load unknown item #{k}" if item.nil?
|
762
|
+
next unless v[:qty].positive?
|
763
|
+
|
764
|
+
OpenStruct.new(
|
765
|
+
name: k.to_sym,
|
766
|
+
label: v[:label].presence || k.to_s.humanize,
|
767
|
+
qty: v[:qty],
|
768
|
+
equipped: false,
|
769
|
+
weight: item[:weight]
|
770
|
+
)
|
771
|
+
end.compact
|
772
|
+
end
|
773
|
+
|
774
|
+
def inventory_count
|
775
|
+
@inventory.values.inject(0) do |total, item|
|
776
|
+
total + item[:qty]
|
777
|
+
end
|
778
|
+
end
|
779
|
+
|
780
|
+
# Unequips a weapon
|
781
|
+
# @param item_name [String,Symbol]
|
782
|
+
# @param transfer_inventory [Boolean] Add this item to the inventory?
|
783
|
+
def unequip(item_name, transfer_inventory: true)
|
784
|
+
add_item(item_name.to_sym) if @properties[:equipped].delete(item_name.to_s) && transfer_inventory
|
785
|
+
end
|
786
|
+
|
787
|
+
# removes all equiped. Used for tests
|
788
|
+
def unequip_all
|
789
|
+
@properties[:equipped].clear
|
790
|
+
end
|
791
|
+
|
792
|
+
# Checks if an item is equipped
|
793
|
+
# @param item_name [String,Symbol]
|
794
|
+
# @return [Boolean]
|
795
|
+
def equipped?(item_name)
|
796
|
+
equipped_items.map(&:name).include?(item_name.to_sym)
|
797
|
+
end
|
798
|
+
|
799
|
+
# Equips an item
|
800
|
+
# @param item_name [String,Symbol]
|
801
|
+
def equip(item_name, ignore_inventory: false)
|
802
|
+
return @properties[:equipped] << item_name.to_s if ignore_inventory
|
803
|
+
|
804
|
+
item = deduct_item(item_name)
|
805
|
+
@properties[:equipped] << item_name.to_s if item
|
806
|
+
end
|
807
|
+
|
808
|
+
# Checks if item can be equipped
|
809
|
+
# @param item_name [String,Symbol]
|
810
|
+
# @return [Symbol]
|
811
|
+
def check_equip(item_name)
|
812
|
+
item_name = item_name.to_sym
|
813
|
+
weapon = @session.load_thing(item_name)
|
814
|
+
return :unequippable unless weapon && weapon[:subtype] == 'weapon' || %w[shield armor].include?(weapon[:type])
|
815
|
+
|
816
|
+
hand_slots = used_hand_slots + hand_slots_required(to_item(item_name, weapon))
|
817
|
+
|
818
|
+
armor_slots = equipped_items.select do |item|
|
819
|
+
item.type == 'armor'
|
820
|
+
end.size
|
821
|
+
|
822
|
+
return :hands_full if hand_slots > 2.0
|
823
|
+
return :armor_equipped if armor_slots >= 1 && weapon[:type] == 'armor'
|
824
|
+
|
825
|
+
:ok
|
826
|
+
end
|
827
|
+
|
828
|
+
def proficient_with_armor?(item)
|
829
|
+
armor = @session.load_thing(item)
|
830
|
+
raise "unknown item #{item}" unless armor
|
831
|
+
raise "not armor #{item}" unless %w[armor shield].include?(armor[:type])
|
832
|
+
|
833
|
+
return proficient?("#{armor[:subtype]}_armor") if armor[:type] == 'armor'
|
834
|
+
return proficient?('shields') if armor[:type] == 'shield'
|
835
|
+
|
836
|
+
false
|
837
|
+
end
|
838
|
+
|
839
|
+
def proficient_with_equipped_armor?
|
840
|
+
shields_and_armor = equipped_items.select { |t| %w[armor shield].include?(t[:type]) }
|
841
|
+
return true if shields_and_armor.empty?
|
842
|
+
|
843
|
+
shields_and_armor.each do |item|
|
844
|
+
return false unless proficient_with_armor?(item.name)
|
845
|
+
end
|
846
|
+
|
847
|
+
true
|
848
|
+
end
|
849
|
+
|
850
|
+
def hand_slots_required(item)
|
851
|
+
return 0.0 if item.type == 'armor'
|
852
|
+
|
853
|
+
if item.light
|
854
|
+
0.5
|
855
|
+
elsif item.two_handed
|
856
|
+
2.0
|
857
|
+
else
|
858
|
+
1.0
|
859
|
+
end
|
860
|
+
end
|
861
|
+
|
862
|
+
def used_hand_slots(weapon_only: false)
|
863
|
+
equipped_items.select do |item|
|
864
|
+
item.subtype == 'weapon' || (!weapon_only && item.type == 'shield')
|
865
|
+
end.inject(0.0) do |slot, item|
|
866
|
+
slot + hand_slots_required(item)
|
867
|
+
end
|
868
|
+
end
|
869
|
+
|
870
|
+
def equipped_weapons
|
871
|
+
equipped_items.select do |item|
|
872
|
+
item.subtype == 'weapon'
|
873
|
+
end.map(&:name)
|
874
|
+
end
|
875
|
+
|
876
|
+
def proficient?(prof)
|
877
|
+
@properties[:skills]&.include?(prof.to_s) ||
|
878
|
+
@properties[:tools]&.include?(prof.to_s) ||
|
879
|
+
|
880
|
+
@properties[:saving_throw_proficiencies]&.map { |s| "#{s}_save" }&.include?(prof.to_s)
|
881
|
+
end
|
882
|
+
|
883
|
+
def opened?
|
884
|
+
false
|
885
|
+
end
|
886
|
+
|
887
|
+
def incapacitated?
|
888
|
+
@statuses.include?(:unconscious) || @statuses.include?(:sleep) || @statuses.include?(:dead)
|
889
|
+
end
|
890
|
+
|
891
|
+
def grappled?
|
892
|
+
@statuses.include?(:grappled)
|
893
|
+
end
|
894
|
+
|
895
|
+
# Returns tghe proficiency bonus of this entity
|
896
|
+
# @return [Integer]
|
897
|
+
def proficiency_bonus
|
898
|
+
@properties[:proficiency_bonus].presence || 2
|
899
|
+
end
|
900
|
+
|
901
|
+
# returns in lbs the weight of all items in the inventory
|
902
|
+
# @return [Float] weight in lbs
|
903
|
+
def inventory_weight
|
904
|
+
(inventory + equipped_items).inject(0.0) do |sum, item|
|
905
|
+
sum + (item.weight.presence || '0').to_f * item.qty
|
906
|
+
end
|
907
|
+
end
|
908
|
+
|
909
|
+
# returns equipped items
|
910
|
+
# @return [Array<OpenStruct>] A List of items
|
911
|
+
def equipped_items
|
912
|
+
equipped_arr = @properties[:equipped] || []
|
913
|
+
equipped_arr.map do |k|
|
914
|
+
item = @session.load_thing(k)
|
915
|
+
raise "unknown item #{k}" unless item
|
916
|
+
|
917
|
+
to_item(k, item)
|
918
|
+
end
|
919
|
+
end
|
920
|
+
|
921
|
+
def shield_equipped?
|
922
|
+
@equipments ||= YAML.load_file(File.join(session.root_path, 'items', 'equipment.yml')).deep_symbolize_keys!
|
923
|
+
|
924
|
+
equipped_meta = @equipped.map { |e| @equipments[e.to_sym] }.compact
|
925
|
+
!!equipped_meta.detect do |s|
|
926
|
+
s[:type] == 'shield'
|
927
|
+
end
|
928
|
+
end
|
929
|
+
|
930
|
+
def to_item(k, item)
|
931
|
+
OpenStruct.new(
|
932
|
+
name: k.to_sym,
|
933
|
+
label: item[:label].presence || k.to_s.humanize,
|
934
|
+
type: item[:type],
|
935
|
+
subtype: item[:subtype],
|
936
|
+
light: item[:properties].try(:include?, 'light'),
|
937
|
+
two_handed: item[:properties].try(:include?, 'two_handed'),
|
938
|
+
light_properties: item[:light],
|
939
|
+
proficiency_type: item[:proficiency_type],
|
940
|
+
qty: 1,
|
941
|
+
equipped: true,
|
942
|
+
weight: item[:weight]
|
943
|
+
)
|
944
|
+
end
|
945
|
+
|
946
|
+
# returns the carrying capacity of an entity in lbs
|
947
|
+
# @return [Float] carrying capacity in lbs
|
948
|
+
def carry_capacity
|
949
|
+
@ability_scores.fetch(:str, 1) * 15.0
|
950
|
+
end
|
951
|
+
|
952
|
+
def items_label
|
953
|
+
I18n.t(:"entity.#{self.class}.item_label", default: "#{name} Items")
|
954
|
+
end
|
955
|
+
|
956
|
+
def perception_proficient?
|
957
|
+
proficient?('perception')
|
958
|
+
end
|
959
|
+
|
960
|
+
def investigation_proficient?
|
961
|
+
proficient?('investigation')
|
962
|
+
end
|
963
|
+
|
964
|
+
def insight_proficient?
|
965
|
+
proficient?('insight')
|
966
|
+
end
|
967
|
+
|
968
|
+
def stealth_proficient?
|
969
|
+
proficient?('stealth')
|
970
|
+
end
|
971
|
+
|
972
|
+
def acrobatics_proficient?
|
973
|
+
proficient?('acrobatics')
|
974
|
+
end
|
975
|
+
|
976
|
+
def athletics_proficient?
|
977
|
+
proficient?('athletics')
|
978
|
+
end
|
979
|
+
|
980
|
+
def medicine_proficient?
|
981
|
+
proficient?('medicine')
|
982
|
+
end
|
983
|
+
|
984
|
+
def lockpick!(battle = nil)
|
985
|
+
proficiency_mod = dex_mod
|
986
|
+
bonus = if proficient?(:thieves_tools)
|
987
|
+
expertise?(:thieves_tools) ? proficiency_bonus * 2 : proficiency_bonus
|
988
|
+
else
|
989
|
+
0
|
990
|
+
end
|
991
|
+
proficiency_mod += bonus
|
992
|
+
Natural20::DieRoll.roll("1d20+#{proficiency_mod}", description: t('dice_roll.thieves_tools'), battle: battle,
|
993
|
+
entity: self)
|
994
|
+
end
|
995
|
+
|
996
|
+
def class_feature?(feature)
|
997
|
+
@properties[:attributes]&.include?(feature)
|
998
|
+
end
|
999
|
+
|
1000
|
+
# checks if at least one class feature matches
|
1001
|
+
# @param features [Array<String>]
|
1002
|
+
# @return [Boolean]
|
1003
|
+
def any_class_feature?(features)
|
1004
|
+
!features.select { |f| class_feature?(f) }.empty?
|
1005
|
+
end
|
1006
|
+
|
1007
|
+
def light_properties
|
1008
|
+
return nil if equipped_items.blank?
|
1009
|
+
|
1010
|
+
bright = [0]
|
1011
|
+
dim = [0]
|
1012
|
+
|
1013
|
+
equipped_items.map do |item|
|
1014
|
+
next unless item.light_properties
|
1015
|
+
|
1016
|
+
bright << item.light_properties.fetch(:bright, 0)
|
1017
|
+
dim << item.light_properties.fetch(:dim, 0)
|
1018
|
+
end
|
1019
|
+
|
1020
|
+
bright = bright.max
|
1021
|
+
dim = dim.max
|
1022
|
+
|
1023
|
+
return nil unless [dim, bright].sum.positive?
|
1024
|
+
|
1025
|
+
{ dim: dim,
|
1026
|
+
bright: bright }
|
1027
|
+
end
|
1028
|
+
|
1029
|
+
def attack_roll_mod(weapon)
|
1030
|
+
modifier = attack_ability_mod(weapon)
|
1031
|
+
|
1032
|
+
modifier += proficiency_bonus if proficient_with_weapon?(weapon)
|
1033
|
+
|
1034
|
+
modifier
|
1035
|
+
end
|
1036
|
+
|
1037
|
+
def attack_ability_mod(weapon)
|
1038
|
+
modifier = 0
|
1039
|
+
|
1040
|
+
modifier += case (weapon[:type])
|
1041
|
+
when 'melee_attack'
|
1042
|
+
weapon[:properties]&.include?('finesse') ? [str_mod, dex_mod].max : str_mod
|
1043
|
+
when 'ranged_attack'
|
1044
|
+
if class_feature?('archery')
|
1045
|
+
dex_mod + 2
|
1046
|
+
else
|
1047
|
+
dex_mod
|
1048
|
+
end
|
1049
|
+
end
|
1050
|
+
|
1051
|
+
modifier
|
1052
|
+
end
|
1053
|
+
|
1054
|
+
def proficient_with_weapon?(weapon)
|
1055
|
+
weapon = @session.load_thing weapon if weapon.is_a?(String)
|
1056
|
+
|
1057
|
+
return true if weapon[:name] == 'Unarmed Attack'
|
1058
|
+
|
1059
|
+
@properties[:weapon_proficiencies]&.detect do |prof|
|
1060
|
+
weapon[:proficiency_type]&.include?(prof)
|
1061
|
+
end
|
1062
|
+
end
|
1063
|
+
|
1064
|
+
# Returns the character hit die
|
1065
|
+
# @return [Hash<Integer,Integer>]
|
1066
|
+
def hit_die
|
1067
|
+
@current_hit_die
|
1068
|
+
end
|
1069
|
+
|
1070
|
+
def squeezed!
|
1071
|
+
@statuses.add(:squeezed)
|
1072
|
+
end
|
1073
|
+
|
1074
|
+
def unsqueeze
|
1075
|
+
@statuses.delete(:squeezed)
|
1076
|
+
end
|
1077
|
+
|
1078
|
+
def squeezed?
|
1079
|
+
@statuses.include?(:squeezed)
|
1080
|
+
end
|
1081
|
+
|
1082
|
+
# @param hit_die_num [Integer] number of hit die to use
|
1083
|
+
def short_rest!(battle, prompt: false)
|
1084
|
+
controller = battle&.controller_for(self)
|
1085
|
+
|
1086
|
+
# hit die management
|
1087
|
+
if prompt && controller && controller.respond_to?(:prompt_hit_die_roll)
|
1088
|
+
loop do
|
1089
|
+
break unless @current_hit_die.values.inject(0) { |sum, d| sum + d }.positive?
|
1090
|
+
|
1091
|
+
ans = battle.controller_for(self)&.try(:prompt_hit_die_roll, self, @current_hit_die.select do |_k, v|
|
1092
|
+
v.positive?
|
1093
|
+
end.keys)
|
1094
|
+
|
1095
|
+
if ans == :skip
|
1096
|
+
break
|
1097
|
+
else
|
1098
|
+
use_hit_die!(ans, battle: battle)
|
1099
|
+
end
|
1100
|
+
end
|
1101
|
+
else
|
1102
|
+
while @hp < max_hp
|
1103
|
+
available_die = @current_hit_die.map do |die, num|
|
1104
|
+
next unless num.positive?
|
1105
|
+
|
1106
|
+
die
|
1107
|
+
end.compact.sort
|
1108
|
+
|
1109
|
+
break if available_die.empty?
|
1110
|
+
|
1111
|
+
old_hp = @hp
|
1112
|
+
|
1113
|
+
use_hit_die!(available_die.first, battle: battle)
|
1114
|
+
|
1115
|
+
break if @hp == old_hp # break if unable to heal
|
1116
|
+
end
|
1117
|
+
end
|
1118
|
+
|
1119
|
+
heal!(1) if unconscious? && stable?
|
1120
|
+
end
|
1121
|
+
|
1122
|
+
def use_hit_die!(die_type, battle: nil)
|
1123
|
+
return unless @current_hit_die.key? die_type
|
1124
|
+
return unless @current_hit_die[die_type].positive?
|
1125
|
+
|
1126
|
+
@current_hit_die[die_type] -= 1
|
1127
|
+
|
1128
|
+
hit_die_roll = DieRoll.roll("d#{die_type}", battle: battle, entity: self, description: t('dice_roll.hit_die'))
|
1129
|
+
|
1130
|
+
EventManager.received_event({ source: self, event: :hit_die, roll: hit_die_roll })
|
1131
|
+
|
1132
|
+
heal!(hit_die_roll.result)
|
1133
|
+
end
|
1134
|
+
|
1135
|
+
def saving_throw!(save_type, battle: nil)
|
1136
|
+
modifier = ability_mod(save_type)
|
1137
|
+
modifier += proficiency_bonus if proficient?("#{save_type}_save")
|
1138
|
+
op = modifier >= 0 ? '+' : ''
|
1139
|
+
disadvantage = %i[dex str].include?(save_type.to_sym) && !proficient_with_equipped_armor? ? true : false
|
1140
|
+
DieRoll.roll("d20#{op}#{modifier}", disadvantage: disadvantage, battle: battle, entity: self,
|
1141
|
+
description: t("dice_roll.#{save_type}_saving_throw"))
|
1142
|
+
end
|
1143
|
+
|
1144
|
+
protected
|
1145
|
+
|
1146
|
+
# Localization helper
|
1147
|
+
# @param token [Symbol, String]
|
1148
|
+
# @param options [Hash]
|
1149
|
+
# @return [String]
|
1150
|
+
def t(token, options = {})
|
1151
|
+
I18n.t(token, options)
|
1152
|
+
end
|
1153
|
+
|
1154
|
+
def setup_attributes
|
1155
|
+
@death_saves = 0
|
1156
|
+
@death_fails = 0
|
1157
|
+
end
|
1158
|
+
|
1159
|
+
def on_take_damage(battle, _damage_params)
|
1160
|
+
controller = battle.controller_for(self)
|
1161
|
+
controller.attack_listener(battle, self) if controller && controller.respond_to?(:attack_listener)
|
1162
|
+
end
|
1163
|
+
|
1164
|
+
def modifier_table(value)
|
1165
|
+
mod_table = [[1, 1, -5],
|
1166
|
+
[2, 3, -4],
|
1167
|
+
[4, 5, -3],
|
1168
|
+
[6, 7, -2],
|
1169
|
+
[8, 9, -1],
|
1170
|
+
[10, 11, 0],
|
1171
|
+
[12, 13, 1],
|
1172
|
+
[14, 15, 2],
|
1173
|
+
[16, 17, 3],
|
1174
|
+
[18, 19, 4],
|
1175
|
+
[20, 21, 5],
|
1176
|
+
[22, 23, 6],
|
1177
|
+
[24, 25, 7],
|
1178
|
+
[26, 27, 8],
|
1179
|
+
[28, 29, 9],
|
1180
|
+
[30, 30, 10]]
|
1181
|
+
|
1182
|
+
mod_table.each do |row|
|
1183
|
+
low, high, mod = row
|
1184
|
+
return mod if value.between?(low, high)
|
1185
|
+
end
|
1186
|
+
end
|
1187
|
+
|
1188
|
+
def character_advancement_table
|
1189
|
+
[
|
1190
|
+
[0, 1, 2],
|
1191
|
+
[300, 2, 2],
|
1192
|
+
[900, 3, 2],
|
1193
|
+
[2700, 4, 2],
|
1194
|
+
[6500, 5, 3],
|
1195
|
+
[14_000, 6, 3],
|
1196
|
+
[23_000, 7, 3],
|
1197
|
+
[34_000, 8, 3],
|
1198
|
+
[48_000, 9, 4],
|
1199
|
+
[64_000, 10, 4],
|
1200
|
+
[85_000, 11, 4],
|
1201
|
+
[100_000, 12, 4],
|
1202
|
+
[120_000, 13, 5],
|
1203
|
+
[140_000, 14, 5],
|
1204
|
+
[165_000, 15, 5],
|
1205
|
+
[195_000, 16, 5],
|
1206
|
+
[225_000, 17, 6],
|
1207
|
+
[265_000, 18, 6],
|
1208
|
+
[305_000, 19, 6],
|
1209
|
+
[355_000, 20, 6]
|
1210
|
+
]
|
1211
|
+
end
|
1212
|
+
end
|
1213
|
+
end
|