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,69 @@
|
|
1
|
+
module ItemLibrary
|
2
|
+
# Represents a staple of DnD the concealed pit trap
|
3
|
+
class PitTrap < Object
|
4
|
+
include AreaTrigger
|
5
|
+
|
6
|
+
attr_accessor :activated
|
7
|
+
|
8
|
+
def area_trigger_handler(entity, entity_pos, is_flying)
|
9
|
+
result = []
|
10
|
+
return nil if entity_pos != position
|
11
|
+
return nil if is_flying
|
12
|
+
|
13
|
+
unless activated
|
14
|
+
damage = Natural20::DieRoll.roll(@properties[:damage_die])
|
15
|
+
result = [
|
16
|
+
{
|
17
|
+
source: self,
|
18
|
+
type: :state,
|
19
|
+
params: {
|
20
|
+
activated: true
|
21
|
+
}
|
22
|
+
},
|
23
|
+
{
|
24
|
+
source: self,
|
25
|
+
target: entity,
|
26
|
+
type: :damage,
|
27
|
+
attack_name: @properties[:attack_name] || 'pit trap',
|
28
|
+
damage_type: @properties[:damage_type] || 'piercing',
|
29
|
+
damage: damage
|
30
|
+
}
|
31
|
+
]
|
32
|
+
end
|
33
|
+
|
34
|
+
result
|
35
|
+
end
|
36
|
+
|
37
|
+
def placeable?
|
38
|
+
!activated
|
39
|
+
end
|
40
|
+
|
41
|
+
def label
|
42
|
+
return 'ground' unless activated
|
43
|
+
|
44
|
+
@properties[:name].presence || 'pit trap'
|
45
|
+
end
|
46
|
+
|
47
|
+
def passable?
|
48
|
+
true
|
49
|
+
end
|
50
|
+
|
51
|
+
def token
|
52
|
+
["\u02ac"]
|
53
|
+
end
|
54
|
+
|
55
|
+
def concealed?
|
56
|
+
!activated
|
57
|
+
end
|
58
|
+
|
59
|
+
def jump_required?
|
60
|
+
activated
|
61
|
+
end
|
62
|
+
|
63
|
+
protected
|
64
|
+
|
65
|
+
def setup_other_attributes
|
66
|
+
@activated = false
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
@@ -0,0 +1,173 @@
|
|
1
|
+
# typed: false
|
2
|
+
require "random_name_generator"
|
3
|
+
|
4
|
+
module Natural20
|
5
|
+
class Npc
|
6
|
+
include Natural20::Entity
|
7
|
+
include Natural20::Notable
|
8
|
+
prepend Natural20::Lootable
|
9
|
+
include Natural20::HealthFlavor
|
10
|
+
include Multiattack
|
11
|
+
|
12
|
+
attr_accessor :hp, :resistances, :npc_actions, :battle_defaults, :npc_type
|
13
|
+
|
14
|
+
# @param session [Session]
|
15
|
+
# @param type [String,Symbol]
|
16
|
+
# @option opt rand_life [Boolean] Determines if will use die for npc HP instead of fixed value
|
17
|
+
def initialize(session, type, opt = {})
|
18
|
+
@properties = YAML.load_file(File.join("npcs", "#{type}.yml")).deep_symbolize_keys!
|
19
|
+
@properties.merge!(opt[:overrides].presence || {})
|
20
|
+
@ability_scores = @properties[:ability]
|
21
|
+
@color = @properties[:color]
|
22
|
+
@session = session
|
23
|
+
@npc_type = type
|
24
|
+
@inventory = @properties[:default_inventory]&.map do |inventory|
|
25
|
+
[inventory[:type].to_sym, OpenStruct.new({ qty: inventory[:qty] })]
|
26
|
+
end.to_h || {}
|
27
|
+
|
28
|
+
@properties[:inventory]&.each do |inventory|
|
29
|
+
@inventory[inventory[:type].to_sym] = OpenStruct.new({ qty: inventory[:qty] })
|
30
|
+
end
|
31
|
+
|
32
|
+
@npc_actions = @properties[:actions]
|
33
|
+
@battle_defaults = @properties[:battle_defaults]
|
34
|
+
@opt = opt
|
35
|
+
@resistances = []
|
36
|
+
@statuses = Set.new
|
37
|
+
|
38
|
+
@properties[:statuses]&.each do |stat|
|
39
|
+
@statuses.add(stat.to_sym)
|
40
|
+
end
|
41
|
+
|
42
|
+
name = case type
|
43
|
+
when "goblin"
|
44
|
+
RandomNameGenerator.new(RandomNameGenerator::GOBLIN).compose(1)
|
45
|
+
when "ogre"
|
46
|
+
%w[Guzar Irth Grukurg Zoduk].sample(1).first
|
47
|
+
else
|
48
|
+
type.to_s.humanize
|
49
|
+
end
|
50
|
+
@name = opt.fetch(:name, name)
|
51
|
+
@entity_uid = SecureRandom.uuid
|
52
|
+
setup_attributes
|
53
|
+
end
|
54
|
+
|
55
|
+
attr_reader :name
|
56
|
+
|
57
|
+
def kind
|
58
|
+
@properties[:kind]
|
59
|
+
end
|
60
|
+
|
61
|
+
def size
|
62
|
+
@properties[:size]
|
63
|
+
end
|
64
|
+
|
65
|
+
def token
|
66
|
+
@properties[:token]
|
67
|
+
end
|
68
|
+
|
69
|
+
attr_reader :name
|
70
|
+
|
71
|
+
attr_reader :max_hp
|
72
|
+
|
73
|
+
def npc?
|
74
|
+
true
|
75
|
+
end
|
76
|
+
|
77
|
+
def armor_class
|
78
|
+
@properties[:default_ac]
|
79
|
+
end
|
80
|
+
|
81
|
+
def speed
|
82
|
+
@properties[:speed]
|
83
|
+
end
|
84
|
+
|
85
|
+
def available_actions(session, battle, opportunity_attack: false)
|
86
|
+
return %i[end] if unconscious?
|
87
|
+
|
88
|
+
if opportunity_attack
|
89
|
+
return generate_npc_attack_actions(battle, opportunity_attack: true).select do |s|
|
90
|
+
s.action_type == :attack && s.npc_action[:type] == 'melee_attack'
|
91
|
+
end
|
92
|
+
end
|
93
|
+
|
94
|
+
[ generate_npc_attack_actions(battle) +
|
95
|
+
|
96
|
+
%i[hide dodge look stand move dash grapple escape_grapple].map do |type|
|
97
|
+
next unless "#{type.to_s.camelize}Action".constantize.can?(self, battle)
|
98
|
+
case type
|
99
|
+
when :dodge
|
100
|
+
DodgeAction.new(session, self, :dodge)
|
101
|
+
when :hide
|
102
|
+
HideAction.new(session, self, :hide)
|
103
|
+
when :disengage
|
104
|
+
action = DisengageAction.new(session, self, :disengage)
|
105
|
+
action
|
106
|
+
when :move
|
107
|
+
MoveAction.new(session, self, type)
|
108
|
+
when :stand
|
109
|
+
StandAction.new(session, self, type)
|
110
|
+
when :dash
|
111
|
+
action = DashAction.new(session, self, type)
|
112
|
+
action
|
113
|
+
when :help
|
114
|
+
action = HelpAction.new(session, self, :help)
|
115
|
+
action
|
116
|
+
else
|
117
|
+
Natural20::Action.new(session, self, type)
|
118
|
+
end
|
119
|
+
end.compact].flatten
|
120
|
+
end
|
121
|
+
|
122
|
+
def melee_distance
|
123
|
+
@properties[:actions].select { |a| a[:type] == "melee_attack" }.map do |action|
|
124
|
+
action[:range]
|
125
|
+
end&.max
|
126
|
+
end
|
127
|
+
|
128
|
+
def class_feature?(feature)
|
129
|
+
@properties[:attributes]&.include?(feature)
|
130
|
+
end
|
131
|
+
|
132
|
+
def available_interactions(entity, battle)
|
133
|
+
[]
|
134
|
+
end
|
135
|
+
|
136
|
+
def proficient_with_equipped_armor?
|
137
|
+
true
|
138
|
+
end
|
139
|
+
|
140
|
+
def generate_npc_attack_actions(battle, opportunity_attack: false)
|
141
|
+
actions = []
|
142
|
+
|
143
|
+
actions += npc_actions.map do |npc_action|
|
144
|
+
next if npc_action[:ammo] && item_count(npc_action[:ammo]) <= 0
|
145
|
+
next if npc_action[:if] && !eval_if(npc_action[:if])
|
146
|
+
next unless AttackAction.can?(self, battle, npc_action: npc_action, opportunity_attack: opportunity_attack)
|
147
|
+
|
148
|
+
action = AttackAction.new(session, self, :attack)
|
149
|
+
|
150
|
+
action.npc_action = npc_action
|
151
|
+
action
|
152
|
+
end.compact
|
153
|
+
|
154
|
+
actions
|
155
|
+
end
|
156
|
+
|
157
|
+
private
|
158
|
+
|
159
|
+
def setup_attributes
|
160
|
+
super
|
161
|
+
|
162
|
+
@max_hp = @opt[:rand_life] ? Natural20::DieRoll.roll(@properties[:hp_die]).result : @properties[:max_hp]
|
163
|
+
@hp = [@properties.fetch(:override_hp, @max_hp), @max_hp].min
|
164
|
+
|
165
|
+
# parse hit die details
|
166
|
+
hp_details = Natural20::DieRoll.parse(@properties[:hp_die] || "1d6")
|
167
|
+
@max_hit_die = {}
|
168
|
+
@current_hit_die = {}
|
169
|
+
@max_hit_die[npc_type] = hp_details.die_count
|
170
|
+
@current_hit_die[hp_details.die_type.to_i] = hp_details.die_count
|
171
|
+
end
|
172
|
+
end
|
173
|
+
end
|
@@ -0,0 +1,414 @@
|
|
1
|
+
# typed: false
|
2
|
+
module Natural20
|
3
|
+
class PlayerCharacter
|
4
|
+
include Natural20::Entity
|
5
|
+
include Natural20::RogueClass
|
6
|
+
include Natural20::FighterClass
|
7
|
+
include Natural20::HealthFlavor
|
8
|
+
prepend Natural20::Lootable
|
9
|
+
include Multiattack
|
10
|
+
|
11
|
+
attr_accessor :hp, :other_counters, :resistances, :experience_points, :class_properties
|
12
|
+
|
13
|
+
ACTION_LIST = %i[first_aid look attack move dash hide help dodge disengage use_item interact ground_interact inventory disengage_bonus
|
14
|
+
dash_bonus hide_bonus grapple escape_grapple drop_grapple shove push prone stand short_rest two_weapon_attack].freeze
|
15
|
+
|
16
|
+
# @param session [Natural20::Session]
|
17
|
+
def initialize(session, properties)
|
18
|
+
@session = session
|
19
|
+
@properties = properties.deep_symbolize_keys!
|
20
|
+
|
21
|
+
@ability_scores = @properties[:ability]
|
22
|
+
@equipped = @properties[:equipped]
|
23
|
+
@race_properties = YAML.load_file(File.join(session.root_path, 'races',
|
24
|
+
"#{@properties[:race]}.yml")).deep_symbolize_keys!
|
25
|
+
@inventory = {}
|
26
|
+
@color = @properties[:color]
|
27
|
+
@properties[:inventory]&.each do |inventory|
|
28
|
+
@inventory[inventory[:type].to_sym] ||= OpenStruct.new({ type: inventory[:type], qty: 0 })
|
29
|
+
@inventory[inventory[:type].to_sym].qty += inventory[:qty]
|
30
|
+
end
|
31
|
+
@statuses = Set.new
|
32
|
+
@resistances = []
|
33
|
+
entity_uid = SecureRandom.uuid
|
34
|
+
setup_attributes
|
35
|
+
@max_hit_die = {}
|
36
|
+
@current_hit_die = {}
|
37
|
+
|
38
|
+
@class_properties = @properties[:classes].map do |klass, level|
|
39
|
+
send(:"#{klass}_level=", level)
|
40
|
+
send(:"initialize_#{klass}")
|
41
|
+
|
42
|
+
@max_hit_die[klass] = level
|
43
|
+
|
44
|
+
character_class_properties =
|
45
|
+
YAML.load_file(File.join(session.root_path, 'char_classes', "#{klass}.yml")).deep_symbolize_keys!
|
46
|
+
hit_die_details = DieRoll.parse(character_class_properties[:hit_die])
|
47
|
+
@current_hit_die[hit_die_details.die_type.to_i] = level
|
48
|
+
|
49
|
+
[klass.to_sym, character_class_properties]
|
50
|
+
end.to_h
|
51
|
+
end
|
52
|
+
|
53
|
+
def name
|
54
|
+
@properties[:name]
|
55
|
+
end
|
56
|
+
|
57
|
+
def max_hp
|
58
|
+
@properties[:max_hp]
|
59
|
+
end
|
60
|
+
|
61
|
+
def armor_class
|
62
|
+
equipped_ac
|
63
|
+
end
|
64
|
+
|
65
|
+
def level
|
66
|
+
@properties[:level]
|
67
|
+
end
|
68
|
+
|
69
|
+
def size
|
70
|
+
@properties[:size] || @race_properties[:size]
|
71
|
+
end
|
72
|
+
|
73
|
+
def token
|
74
|
+
@properties[:token]
|
75
|
+
end
|
76
|
+
|
77
|
+
def speed
|
78
|
+
if subrace
|
79
|
+
return (@race_properties.dig(:subrace, subrace.to_sym,
|
80
|
+
:base_speed) || @race_properties[:base_speed])
|
81
|
+
end
|
82
|
+
@race_properties[:base_speed]
|
83
|
+
end
|
84
|
+
|
85
|
+
def race
|
86
|
+
@properties[:race]
|
87
|
+
end
|
88
|
+
|
89
|
+
def subrace
|
90
|
+
@properties[:subrace]
|
91
|
+
end
|
92
|
+
|
93
|
+
def languages
|
94
|
+
class_languages = []
|
95
|
+
@class_properties.values.each do |prop|
|
96
|
+
class_languages += prop[:languages] || []
|
97
|
+
end
|
98
|
+
|
99
|
+
racial_languages = @race_properties[:languages] || []
|
100
|
+
|
101
|
+
(super + class_languages + racial_languages).sort
|
102
|
+
end
|
103
|
+
|
104
|
+
def c_class
|
105
|
+
@properties[:classes]
|
106
|
+
end
|
107
|
+
|
108
|
+
def passive_perception
|
109
|
+
10 + wis_mod + wisdom_proficiency
|
110
|
+
end
|
111
|
+
|
112
|
+
def passive_investigation
|
113
|
+
10 + int_mod + investigation_proficiency
|
114
|
+
end
|
115
|
+
|
116
|
+
def passive_insight
|
117
|
+
10 + wis_mod + insight_proficiency
|
118
|
+
end
|
119
|
+
|
120
|
+
def wisdom_proficiency
|
121
|
+
perception_proficient? ? proficiency_bonus : 0
|
122
|
+
end
|
123
|
+
|
124
|
+
def investigation_proficiency
|
125
|
+
investigation_proficient? ? proficiency_bonus : 0
|
126
|
+
end
|
127
|
+
|
128
|
+
def insight_proficiency
|
129
|
+
insight_proficient? ? proficiency_bonus : 0
|
130
|
+
end
|
131
|
+
|
132
|
+
def proficiency_bonus
|
133
|
+
proficiency_bonus_table[level - 1]
|
134
|
+
end
|
135
|
+
|
136
|
+
def proficient?(prof)
|
137
|
+
return true if @class_properties.values.detect { |c| c[:proficiencies]&.include?(prof) }
|
138
|
+
return true if @race_properties[:skills]&.include?(prof)
|
139
|
+
return true if weapon_proficiencies.include?(prof)
|
140
|
+
|
141
|
+
super
|
142
|
+
end
|
143
|
+
|
144
|
+
def proficient_with_weapon?(weapon)
|
145
|
+
weapon = @session.load_thing weapon if weapon.is_a?(String)
|
146
|
+
|
147
|
+
all_weapon_proficiencies = weapon_proficiencies
|
148
|
+
|
149
|
+
return true if all_weapon_proficiencies.include?(weapon[:name])
|
150
|
+
|
151
|
+
all_weapon_proficiencies&.detect do |prof|
|
152
|
+
weapon[:proficiency_type]&.include?(prof)
|
153
|
+
end
|
154
|
+
end
|
155
|
+
|
156
|
+
def weapon_proficiencies
|
157
|
+
all_weapon_proficiencies = @class_properties.values.map do |p|
|
158
|
+
p[:weapon_proficiencies]
|
159
|
+
end.compact.flatten + @properties.fetch(:weapon_proficiencies, [])
|
160
|
+
|
161
|
+
all_weapon_proficiencies += @race_properties.fetch(:weapon_proficiencies, [])
|
162
|
+
if subrace
|
163
|
+
all_weapon_proficiencies += (@race_properties.dig(:subrace, subrace.to_sym,
|
164
|
+
:weapon_proficiencies) || [])
|
165
|
+
end
|
166
|
+
|
167
|
+
all_weapon_proficiencies
|
168
|
+
end
|
169
|
+
|
170
|
+
def to_h
|
171
|
+
{
|
172
|
+
name: name,
|
173
|
+
classes: c_class,
|
174
|
+
hp: hp,
|
175
|
+
ability: {
|
176
|
+
str: @ability_scores.fetch(:str),
|
177
|
+
dex: @ability_scores.fetch(:dex),
|
178
|
+
con: @ability_scores.fetch(:con),
|
179
|
+
int: @ability_scores.fetch(:int),
|
180
|
+
wis: @ability_scores.fetch(:wis),
|
181
|
+
cha: @ability_scores.fetch(:cha)
|
182
|
+
},
|
183
|
+
passive: {
|
184
|
+
perception: passive_perception,
|
185
|
+
investigation: passive_investigation,
|
186
|
+
insight: passive_insight
|
187
|
+
}
|
188
|
+
}
|
189
|
+
end
|
190
|
+
|
191
|
+
def melee_distance
|
192
|
+
(@properties[:equipped].map do |item|
|
193
|
+
weapon_detail = session.load_weapon(item)
|
194
|
+
next if weapon_detail.nil?
|
195
|
+
next unless weapon_detail[:type] == 'melee_attack'
|
196
|
+
|
197
|
+
weapon_detail[:range]
|
198
|
+
end.compact + [5]).max
|
199
|
+
end
|
200
|
+
|
201
|
+
def darkvision?(distance)
|
202
|
+
return true if super
|
203
|
+
|
204
|
+
!!(@race_properties[:darkvision] && @race_properties[:darkvision] >= distance)
|
205
|
+
end
|
206
|
+
|
207
|
+
def player_character_attack_actions(_battle, opportunity_attack: false)
|
208
|
+
# check all equipped and create attack for each
|
209
|
+
valid_weapon_types = if opportunity_attack
|
210
|
+
%w[melee_attack]
|
211
|
+
else
|
212
|
+
%w[ranged_attack melee_attack]
|
213
|
+
end
|
214
|
+
|
215
|
+
weapon_attacks = @properties[:equipped].map do |item|
|
216
|
+
weapon_detail = session.load_weapon(item)
|
217
|
+
next if weapon_detail.nil?
|
218
|
+
next unless valid_weapon_types.include?(weapon_detail[:type])
|
219
|
+
next if weapon_detail[:ammo] && !item_count(weapon_detail[:ammo]).positive?
|
220
|
+
|
221
|
+
attacks = []
|
222
|
+
|
223
|
+
action = AttackAction.new(session, self, :attack)
|
224
|
+
action.using = item
|
225
|
+
attacks << action
|
226
|
+
|
227
|
+
if !opportunity_attack && weapon_detail[:properties] && weapon_detail[:properties].include?('thrown')
|
228
|
+
action = AttackAction.new(session, self, :attack)
|
229
|
+
action.using = item
|
230
|
+
action.thrown = true
|
231
|
+
attacks << action
|
232
|
+
end
|
233
|
+
|
234
|
+
attacks
|
235
|
+
end.flatten.compact
|
236
|
+
|
237
|
+
unarmed_attack = AttackAction.new(session, self, :attack)
|
238
|
+
unarmed_attack.using = 'unarmed_attack'
|
239
|
+
|
240
|
+
weapon_attacks + [unarmed_attack]
|
241
|
+
end
|
242
|
+
|
243
|
+
def available_actions(session, battle, opportunity_attack: false)
|
244
|
+
return [] if unconscious?
|
245
|
+
|
246
|
+
if opportunity_attack && AttackAction.can?(self, battle, opportunity_attack: true)
|
247
|
+
return player_character_attack_actions(battle, opportunity_attack: true)
|
248
|
+
end
|
249
|
+
|
250
|
+
ACTION_LIST.map do |type|
|
251
|
+
next unless "#{type.to_s.camelize}Action".constantize.can?(self, battle)
|
252
|
+
|
253
|
+
case type
|
254
|
+
when :look
|
255
|
+
LookAction.new(session, self, :look)
|
256
|
+
when :attack
|
257
|
+
player_character_attack_actions(battle)
|
258
|
+
when :dodge
|
259
|
+
DodgeAction.new(session, self, :dodge)
|
260
|
+
when :help
|
261
|
+
action = HelpAction.new(session, self, :help)
|
262
|
+
action
|
263
|
+
when :hide
|
264
|
+
HideAction.new(session, self, :hide)
|
265
|
+
when :hide_bonus
|
266
|
+
action = HideBonusAction.new(session, self, :hide_bonus)
|
267
|
+
action.as_bonus_action = true
|
268
|
+
action
|
269
|
+
when :disengage_bonus
|
270
|
+
action = DisengageAction.new(session, self, :disengage_bonus)
|
271
|
+
action.as_bonus_action = true
|
272
|
+
action
|
273
|
+
when :disengage
|
274
|
+
DisengageAction.new(session, self, :disengage)
|
275
|
+
when :drop_grapple
|
276
|
+
DropGrappleAction.new(session, self, :drop_grapple)
|
277
|
+
when :grapple
|
278
|
+
GrappleAction.new(session, self, :grapple)
|
279
|
+
when :escape_grapple
|
280
|
+
EscapeGrappleAction.new(session, self, :escape_grapple)
|
281
|
+
when :move
|
282
|
+
MoveAction.new(session, self, type)
|
283
|
+
when :prone
|
284
|
+
ProneAction.new(session, self, type)
|
285
|
+
when :stand
|
286
|
+
StandAction.new(session, self, type)
|
287
|
+
when :short_rest
|
288
|
+
ShortRestAction.new(session, self, type)
|
289
|
+
when :dash_bonus
|
290
|
+
action = DashBonusAction.new(session, self, :dash_bonus)
|
291
|
+
action.as_bonus_action = true
|
292
|
+
action
|
293
|
+
when :dash
|
294
|
+
action = DashAction.new(session, self, type)
|
295
|
+
action
|
296
|
+
when :use_item
|
297
|
+
UseItemAction.new(session, self, type)
|
298
|
+
when :interact
|
299
|
+
InteractAction.new(session, self, type)
|
300
|
+
when :ground_interact
|
301
|
+
GroundInteractAction.new(session, self, type)
|
302
|
+
when :inventory
|
303
|
+
InventoryAction.new(session, self, type)
|
304
|
+
when :first_aid
|
305
|
+
FirstAidAction.new(session, self, type)
|
306
|
+
when :shove
|
307
|
+
action = ShoveAction.new(session, self, type)
|
308
|
+
action.knock_prone = true
|
309
|
+
action
|
310
|
+
when :two_weapon_attack
|
311
|
+
two_weapon_attack_actions(battle)
|
312
|
+
when :push
|
313
|
+
ShoveAction.new(session, self, type)
|
314
|
+
else
|
315
|
+
Natural20::Action.new(session, self, type)
|
316
|
+
end
|
317
|
+
end.compact.flatten + c_class.keys.map { |c| send(:"special_actions_for_#{c}", session, battle) }.flatten
|
318
|
+
end
|
319
|
+
|
320
|
+
def two_weapon_attack_actions(battle)
|
321
|
+
@properties[:equipped].each do |item|
|
322
|
+
weapon_detail = session.load_weapon(item)
|
323
|
+
next if weapon_detail.nil?
|
324
|
+
next unless weapon_detail[:type] == 'melee_attack'
|
325
|
+
|
326
|
+
next unless weapon_detail[:properties] && weapon_detail[:properties].include?('light') && TwoWeaponAttackAction.can?(
|
327
|
+
self, battle, weapon: weapon_detail[:name]
|
328
|
+
)
|
329
|
+
|
330
|
+
action = TwoWeaponAttackAction.new(session, self, :attack)
|
331
|
+
action.using = item
|
332
|
+
return action
|
333
|
+
end
|
334
|
+
nil
|
335
|
+
end
|
336
|
+
|
337
|
+
def available_interactions(_entity, _battle)
|
338
|
+
[]
|
339
|
+
end
|
340
|
+
|
341
|
+
def class_feature?(feature)
|
342
|
+
return true if @properties[:class_features]&.include?(feature)
|
343
|
+
return true if @properties[:attributes]&.include?(feature)
|
344
|
+
return true if @race_properties[:race_features]&.include?(feature)
|
345
|
+
return true if subrace && @race_properties.dig(:subrace, subrace.to_sym, :class_features)&.include?(feature)
|
346
|
+
return true if subrace && @race_properties.dig(:subrace, subrace.to_sym, :race_features)&.include?(feature)
|
347
|
+
|
348
|
+
@class_properties.values.detect { |p| p[:class_features]&.include?(feature) }
|
349
|
+
end
|
350
|
+
|
351
|
+
# Loads a pregen character from path
|
352
|
+
# @param session [Natural20::Session] The session to use
|
353
|
+
# @param path [String] path to character sheet YAML
|
354
|
+
# @apram override [Hash] override attributes
|
355
|
+
# @return [Natural20::PlayerCharacter] An instance of PlayerCharacter
|
356
|
+
def self.load(session, path, override = {})
|
357
|
+
Natural20::PlayerCharacter.new(session, YAML.load_file(path).deep_symbolize_keys!.merge(override))
|
358
|
+
end
|
359
|
+
|
360
|
+
# returns if an npc or a player character
|
361
|
+
# @return [Boolean]
|
362
|
+
def npc?
|
363
|
+
false
|
364
|
+
end
|
365
|
+
|
366
|
+
def pc?
|
367
|
+
true
|
368
|
+
end
|
369
|
+
|
370
|
+
# @param hit_die_num [Integer] number of hit die to use
|
371
|
+
def short_rest!(battle, prompt: false)
|
372
|
+
super
|
373
|
+
|
374
|
+
@class_properties.keys do |klass|
|
375
|
+
send(:"short_rest_for_#{klass}") if respond_to?(:"short_rest_for_#{klass}")
|
376
|
+
end
|
377
|
+
end
|
378
|
+
|
379
|
+
private
|
380
|
+
|
381
|
+
def proficiency_bonus_table
|
382
|
+
[2, 2, 2, 2, 3, 3, 3, 3, 4, 4, 4, 4, 5, 5, 5, 5, 6, 6, 6, 6]
|
383
|
+
end
|
384
|
+
|
385
|
+
def setup_attributes
|
386
|
+
super
|
387
|
+
@hp = @properties[:max_hp]
|
388
|
+
end
|
389
|
+
|
390
|
+
def equipped_ac
|
391
|
+
@equipments ||= YAML.load_file(File.join(session.root_path, 'items', 'equipment.yml')).deep_symbolize_keys!
|
392
|
+
|
393
|
+
equipped_meta = @equipped.map { |e| @equipments[e.to_sym] }.compact
|
394
|
+
armor = equipped_meta.detect do |equipment|
|
395
|
+
equipment[:type] == 'armor'
|
396
|
+
end
|
397
|
+
|
398
|
+
shield = equipped_meta.detect { |e| e[:type] == 'shield' }
|
399
|
+
|
400
|
+
armor_ac = if armor.nil?
|
401
|
+
10 + dex_mod
|
402
|
+
else
|
403
|
+
armor[:ac] + (if armor[:mod_cap]
|
404
|
+
[dex_mod,
|
405
|
+
armor[:mod_cap]].min
|
406
|
+
else
|
407
|
+
dex_mod
|
408
|
+
end) + (class_feature?('defense') ? 1 : 0)
|
409
|
+
end
|
410
|
+
|
411
|
+
armor_ac + (shield.nil? ? 0 : shield[:bonus_ac])
|
412
|
+
end
|
413
|
+
end
|
414
|
+
end
|