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,288 @@
|
|
1
|
+
# typed: false
|
2
|
+
module AiController
|
3
|
+
# Class used for handling "Standard" NPC AI
|
4
|
+
class Standard < Natural20::Controller
|
5
|
+
include Natural20::MovementHelper
|
6
|
+
include Natural20::Navigation
|
7
|
+
|
8
|
+
attr_reader :battle_data
|
9
|
+
|
10
|
+
def initialize
|
11
|
+
@battle_data = {}
|
12
|
+
end
|
13
|
+
|
14
|
+
def sound_listener(battle, entity, position, stealth); end
|
15
|
+
|
16
|
+
# @param battle [Natural20::Battle]
|
17
|
+
# @param entity [Natural20::Entity]
|
18
|
+
# @param position [Array]
|
19
|
+
def movement_listener(battle, entity, position)
|
20
|
+
move_path = position[:move_path]
|
21
|
+
|
22
|
+
return if move_path.nil?
|
23
|
+
|
24
|
+
# handle unaware npcs
|
25
|
+
new_npcs = battle.map.unaware_npcs.map do |unaware_npc_info|
|
26
|
+
npc = unaware_npc_info[:entity]
|
27
|
+
seen = !!move_path.reverse.detect do |path|
|
28
|
+
npc.conscious? && battle.map.can_see?(npc, entity, entity_2_pos: path)
|
29
|
+
end
|
30
|
+
|
31
|
+
next unless seen
|
32
|
+
|
33
|
+
register_handlers_on(npc) # attach this AI controller to this NPC
|
34
|
+
battle.add(npc, unaware_npc_info[:group])
|
35
|
+
npc
|
36
|
+
end.compact
|
37
|
+
new_npcs.each { |npc| battle.map.unaware_npcs.delete(npc) }
|
38
|
+
|
39
|
+
# update seen info for each npc
|
40
|
+
battle.entities.each_key do |e|
|
41
|
+
loc = move_path.reverse.detect do |location|
|
42
|
+
battle.map.can_see?(entity, e, entity_2_pos: location)
|
43
|
+
end
|
44
|
+
# include friendlies as well since they can turn on us at one point in time :)
|
45
|
+
update_enemy_known_position(e, entity, *loc) if loc
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
def attack_listener(battle, target)
|
50
|
+
unaware_npc_info = battle.map.unaware_npcs.detect { |n| n[:entity] == target }
|
51
|
+
return unless unaware_npc_info
|
52
|
+
|
53
|
+
register_handlers_on(target) # attach this AI controller to this NPC
|
54
|
+
battle.add(target, unaware_npc_info[:group])
|
55
|
+
battle.map.unaware_npcs.delete(target)
|
56
|
+
end
|
57
|
+
|
58
|
+
# @param battle [Natural20::Battle]
|
59
|
+
# @param action [Natural20::Action]
|
60
|
+
def action_listener(battle, action, _opt)
|
61
|
+
# handle unaware npcs
|
62
|
+
new_npcs = battle.map.unaware_npcs.map do |unaware_npc_info|
|
63
|
+
npc = unaware_npc_info[:entity]
|
64
|
+
next unless npc.conscious?
|
65
|
+
next unless battle.map.can_see?(npc, action.source)
|
66
|
+
|
67
|
+
register_handlers_on(npc) # attach this AI controller to this NPC
|
68
|
+
battle.add(npc, unaware_npc_info[:group])
|
69
|
+
update_enemy_known_position(battle, npc, action.source, battle.map.entity_squares(action.source).first)
|
70
|
+
npc
|
71
|
+
end
|
72
|
+
new_npcs.each { |npc| battle.map.unaware_npcs.delete(npc) }
|
73
|
+
end
|
74
|
+
|
75
|
+
def opportunity_attack_listener(battle, session, entity, map, event)
|
76
|
+
entity_x, entity_y = map.position_of(entity)
|
77
|
+
target_x, target_y = event[:position]
|
78
|
+
|
79
|
+
distance = Math.sqrt((target_x - entity_x)**2 + (target_y - entity_y)**2).ceil
|
80
|
+
|
81
|
+
action = entity.available_actions(session, battle, opportunity_attack: true).select do |s|
|
82
|
+
distance <= s.npc_action[:range]
|
83
|
+
end.first
|
84
|
+
|
85
|
+
if action
|
86
|
+
action.target = event[:target]
|
87
|
+
action.as_reaction = true
|
88
|
+
end
|
89
|
+
action
|
90
|
+
end
|
91
|
+
|
92
|
+
def register_battle_listeners(battle)
|
93
|
+
# detects noisy things
|
94
|
+
battle.add_battlefield_event_listener(:sound, self, :sound_listener)
|
95
|
+
|
96
|
+
# detects line of sight movement
|
97
|
+
battle.add_battlefield_event_listener(:movement, self, :movement_listener)
|
98
|
+
|
99
|
+
# actions listener (check if doors opened etc)
|
100
|
+
battle.add_battlefield_event_listener(:interact, self, :action_listener)
|
101
|
+
end
|
102
|
+
|
103
|
+
# @param entity [Natural20::Entity]
|
104
|
+
def register_handlers_on(entity)
|
105
|
+
entity.attach_handler(:opportunity_attack, self, :opportunity_attack_listener)
|
106
|
+
end
|
107
|
+
|
108
|
+
# tests if npc has an appropriate weapon to at least one visible enemy
|
109
|
+
def has_appropriate_weapon?; end
|
110
|
+
|
111
|
+
# Tells AI to compute moves for an entity
|
112
|
+
# @param entity [Natural20::Entity] The entity to compute moves for
|
113
|
+
# @param battle [Natural20::Battle] An instance of the current battle
|
114
|
+
# @return [Array]
|
115
|
+
def move_for(entity, battle)
|
116
|
+
initialize_battle_data(battle, entity)
|
117
|
+
|
118
|
+
known_enemy_positions = @battle_data[battle][entity][:known_enemy_positions]
|
119
|
+
hiding_spots = @battle_data[battle][entity][:hiding_spots]
|
120
|
+
investigate_location = @battle_data[battle][entity][:investigate_location]
|
121
|
+
|
122
|
+
enemy_positions = {}
|
123
|
+
observe_enemies(battle, entity, enemy_positions)
|
124
|
+
|
125
|
+
available_actions = entity.available_actions(@session, battle)
|
126
|
+
|
127
|
+
# generate available targets
|
128
|
+
valid_actions = []
|
129
|
+
|
130
|
+
if enemy_positions.empty? && LookAction.can?(entity, battle)
|
131
|
+
action = LookAction.new(battle.session, entity, :look)
|
132
|
+
return action
|
133
|
+
end
|
134
|
+
|
135
|
+
# try to stand if prone
|
136
|
+
valid_actions << StandAction.new(@session, entity, :stand) if entity.prone? && StandAction.can?(entity, battle)
|
137
|
+
|
138
|
+
available_actions.select { |a| a.action_type == :attack }.each do |action|
|
139
|
+
next unless action.npc_action
|
140
|
+
|
141
|
+
valid_targets = battle.valid_targets_for(entity, action)
|
142
|
+
unless valid_targets.first.nil?
|
143
|
+
action.target = valid_targets.first
|
144
|
+
valid_actions << action
|
145
|
+
end
|
146
|
+
end
|
147
|
+
|
148
|
+
# movement planner if no more attack options and enemies are in sight
|
149
|
+
if valid_actions.empty? && !enemy_positions.empty?
|
150
|
+
valid_actions += generate_moves_for_positions(battle, entity, enemy_positions)
|
151
|
+
end
|
152
|
+
|
153
|
+
# attempt to investigate last seen positions
|
154
|
+
if enemy_positions.empty?
|
155
|
+
my_group = battle.entity_group_for(entity)
|
156
|
+
investigate_location = known_enemy_positions.map do |enemy, position|
|
157
|
+
group = battle.entity_group_for(enemy)
|
158
|
+
next if my_group == group
|
159
|
+
|
160
|
+
[enemy, position]
|
161
|
+
end.compact.to_h
|
162
|
+
|
163
|
+
valid_actions += generate_moves_for_positions(battle, entity, investigate_location)
|
164
|
+
end
|
165
|
+
|
166
|
+
valid_actions << DodgeAction.new(battle.session, entity, :dodge) if entity.action?(battle)
|
167
|
+
|
168
|
+
return valid_actions.first unless valid_actions.empty?
|
169
|
+
end
|
170
|
+
|
171
|
+
protected
|
172
|
+
|
173
|
+
# @param battle [Natural20::Battle]
|
174
|
+
# @param entity [Natural20::Entity]
|
175
|
+
# @param enemy_positions [Hash]
|
176
|
+
# @param use_dash [Boolean]
|
177
|
+
def generate_moves_for_positions(battle, entity, enemy_positions, use_dash: false)
|
178
|
+
valid_actions = []
|
179
|
+
|
180
|
+
path_compute = PathCompute.new(battle, battle.map, entity)
|
181
|
+
start_x, start_y = battle.map.position_of(entity)
|
182
|
+
|
183
|
+
target_squares = evaluate_square(battle.map, battle, entity, enemy_positions.keys)
|
184
|
+
|
185
|
+
squares_priority = target_squares.map do |square, static_eval|
|
186
|
+
range_weight = 1.0
|
187
|
+
melee_weight = 1.0
|
188
|
+
defense_weight = 1.0
|
189
|
+
mobolity_weight = 1.0
|
190
|
+
melee, ranged, defense, mobility, _support = static_eval
|
191
|
+
|
192
|
+
if has_ranged_weapon?(entity)
|
193
|
+
range_weight = 2.0
|
194
|
+
else
|
195
|
+
melee_weight = 2.0
|
196
|
+
end
|
197
|
+
|
198
|
+
defense_weight = 2.0 if (entity.hp / entity.max_hp) < 0.25
|
199
|
+
|
200
|
+
[square, melee_weight * melee + range_weight * ranged + defense_weight * defense + mobility * mobolity_weight]
|
201
|
+
end
|
202
|
+
|
203
|
+
chosen_path = nil
|
204
|
+
|
205
|
+
squares_priority.sort_by! { |a| a[1] }.reverse!.each do |t|
|
206
|
+
return [] if t[0] == [start_x, start_y] # AI thinks its best to not move
|
207
|
+
|
208
|
+
path = path_compute.compute_path(start_x, start_y, *t[0])
|
209
|
+
next if path.nil?
|
210
|
+
|
211
|
+
chosen_path = path
|
212
|
+
break
|
213
|
+
end
|
214
|
+
|
215
|
+
return [] if chosen_path.nil? || chosen_path.empty?
|
216
|
+
|
217
|
+
if entity.available_movement(battle).zero? && use_dash
|
218
|
+
if DashBonusAction.can?(entity, battle)
|
219
|
+
action DashBonusAction.new(battle.session, entity, :dash_bonus)
|
220
|
+
action.as_bonus_action = true
|
221
|
+
valid_actions << action
|
222
|
+
elsif DashAction.can?(entity, battle)
|
223
|
+
valid_actions << DashAction.new(battle.session, entity, :dash)
|
224
|
+
end
|
225
|
+
elsif MoveAction.can?(entity, battle)
|
226
|
+
move_action = MoveAction.new(battle.session, entity, :move)
|
227
|
+
move_budget = entity.available_movement(battle) / battle.map.feet_per_grid
|
228
|
+
shortest_path = compute_actual_moves(entity, chosen_path, battle.map, battle, move_budget).movement
|
229
|
+
return [] if shortest_path.size.zero? || shortest_path.size == 1
|
230
|
+
|
231
|
+
move_action.move_path = shortest_path
|
232
|
+
valid_actions << move_action
|
233
|
+
end
|
234
|
+
|
235
|
+
valid_actions
|
236
|
+
end
|
237
|
+
|
238
|
+
# gain information about enemies in a fair and realistic way (e.g. using line of sight)
|
239
|
+
# @param battle [Natural20::Battle]
|
240
|
+
# @param entity [Natural20::Entity]
|
241
|
+
def observe_enemies(battle, entity, enemy_positions = {})
|
242
|
+
objects_around_me = battle.map.look(entity)
|
243
|
+
|
244
|
+
my_group = battle.entity_group_for(entity)
|
245
|
+
|
246
|
+
objects_around_me.each do |object, location|
|
247
|
+
group = battle.entity_group_for(object)
|
248
|
+
next if group == :none
|
249
|
+
next unless group
|
250
|
+
next unless object.conscious?
|
251
|
+
|
252
|
+
enemy_positions[object] = location if group != my_group
|
253
|
+
end
|
254
|
+
end
|
255
|
+
|
256
|
+
def has_ranged_weapon?(entity)
|
257
|
+
if entity.npc?
|
258
|
+
entity.npc_actions.detect do |npc_action|
|
259
|
+
next if npc_action[:ammo] && entity.item_count(npc_action[:ammo]) <= 0
|
260
|
+
next if npc_action[:if] && !entity.eval_if(npc_action[:if])
|
261
|
+
|
262
|
+
npc_action[:type] == 'ranged_attack'
|
263
|
+
end
|
264
|
+
else
|
265
|
+
# TODO: Player character
|
266
|
+
end
|
267
|
+
end
|
268
|
+
|
269
|
+
# @param battle [Natural20::Battle]
|
270
|
+
# @param entity [Natural20::Entity]
|
271
|
+
# @param enemy [Natural20::Entity]
|
272
|
+
# @param position [Array]
|
273
|
+
def update_enemy_known_position(battle, entity, enemy, position)
|
274
|
+
initialize_battle_data(battle, entity)
|
275
|
+
|
276
|
+
@battle_data[battle][entity][:known_enemy_positions][enemy] = position
|
277
|
+
end
|
278
|
+
|
279
|
+
def initialize_battle_data(battle, entity)
|
280
|
+
@battle_data[battle] ||= {}
|
281
|
+
@battle_data[battle][entity] ||= {
|
282
|
+
known_enemy_positions: {},
|
283
|
+
hiding_spots: {},
|
284
|
+
investigate_location: {}
|
285
|
+
}
|
286
|
+
end
|
287
|
+
end
|
288
|
+
end
|
@@ -0,0 +1,544 @@
|
|
1
|
+
# typed: false
|
2
|
+
module Natural20
|
3
|
+
class Battle
|
4
|
+
attr_accessor :combat_order, :round, :current_party
|
5
|
+
attr_reader :map, :entities, :session, :battle_log, :started, :in_combat
|
6
|
+
|
7
|
+
# Create an instance of a battle
|
8
|
+
# @param session [Natural20::Session]
|
9
|
+
# @param map [Natural20::BattleMap]
|
10
|
+
# @param standard_controller [AiController::Standard]
|
11
|
+
def initialize(session, map, standard_controller = nil)
|
12
|
+
@session = session
|
13
|
+
@entities = {}
|
14
|
+
@groups = {}
|
15
|
+
@battle_field_events = {}
|
16
|
+
@battle_log = []
|
17
|
+
@combat_order = []
|
18
|
+
@late_comers = []
|
19
|
+
@current_turn_index = 0
|
20
|
+
@round = 0
|
21
|
+
@map = map
|
22
|
+
@in_combat = false
|
23
|
+
@standard_controller = standard_controller
|
24
|
+
|
25
|
+
@opposing_groups = {
|
26
|
+
a: [:b],
|
27
|
+
b: [:a]
|
28
|
+
}
|
29
|
+
|
30
|
+
standard_controller&.register_battle_listeners(self)
|
31
|
+
end
|
32
|
+
|
33
|
+
# Registers a player party
|
34
|
+
# @param party [Array<Natural20::Entity>] Initial Player character party
|
35
|
+
# @param controller [Natural20::Controller] An object to be used as callback controller
|
36
|
+
def register_players(party, controller)
|
37
|
+
return if party.blank?
|
38
|
+
|
39
|
+
party.each_with_index do |pc, index|
|
40
|
+
add(pc, :a, position: "spawn_point_#{index + 1}", controller: controller)
|
41
|
+
end
|
42
|
+
@current_party = party
|
43
|
+
@combat_order = party
|
44
|
+
end
|
45
|
+
|
46
|
+
# Updates opposing player groups to determine who is the enemy of who
|
47
|
+
# @param opposing_groups [Hash{Symbol=>Array<Symbol>}]
|
48
|
+
def update_group_dynamics(opposing_groups)
|
49
|
+
@opposing_groups = opposing_groups
|
50
|
+
end
|
51
|
+
|
52
|
+
def add_battlefield_event_listener(event, object, handler)
|
53
|
+
@battle_field_events[event.to_sym] ||= []
|
54
|
+
@battle_field_events[event.to_sym] << [object, handler]
|
55
|
+
end
|
56
|
+
|
57
|
+
def two_weapon_attack?(entity)
|
58
|
+
!!entity_state_for(entity)[:two_weapon]
|
59
|
+
end
|
60
|
+
|
61
|
+
def first_hand_weapon(entity)
|
62
|
+
entity_state_for(entity)[:two_weapon]
|
63
|
+
end
|
64
|
+
|
65
|
+
# Adds an entity to the battle
|
66
|
+
# @param entity [Natural20::Entity] The entity to add to the battle
|
67
|
+
# @param group [Symbol] A symbol denoting which group this entity belongs to
|
68
|
+
# @param controller [AiController::Standard] Ai controller to use
|
69
|
+
# @param position [Array, Symbol] Starting location in the map can be a position or a spawn point
|
70
|
+
# @param token [String, Symbol] The token to use
|
71
|
+
def add(entity, group, controller: nil, position: nil, token: nil)
|
72
|
+
return if @entities[entity]
|
73
|
+
|
74
|
+
raise 'entity cannot be nil' if entity.nil?
|
75
|
+
|
76
|
+
state = {
|
77
|
+
group: group,
|
78
|
+
action: 0,
|
79
|
+
bonus_action: 0,
|
80
|
+
reaction: 0,
|
81
|
+
movement: 0,
|
82
|
+
stealth: 0,
|
83
|
+
statuses: Set.new,
|
84
|
+
active_perception: 0,
|
85
|
+
active_perception_disadvantage: 0,
|
86
|
+
free_object_interaction: 0,
|
87
|
+
target_effect: {},
|
88
|
+
two_weapon: nil,
|
89
|
+
controller: controller || @standard_controller
|
90
|
+
}
|
91
|
+
|
92
|
+
@entities[entity] = state
|
93
|
+
|
94
|
+
battle_defaults = entity.try(:battle_defaults)
|
95
|
+
if battle_defaults
|
96
|
+
battle_defaults[:statuses].each { |s| state[:statuses].add(s.to_sym) }
|
97
|
+
unless state[:stealth].blank?
|
98
|
+
state[:stealth] =
|
99
|
+
DieRoll.roll(battle_defaults[:stealth], description: t('dice_roll.stealth'), entity: entity,
|
100
|
+
battle: self).result
|
101
|
+
end
|
102
|
+
end
|
103
|
+
|
104
|
+
@groups[group] ||= Set.new
|
105
|
+
@groups[group].add(entity)
|
106
|
+
|
107
|
+
# battle already ongoing...
|
108
|
+
if started
|
109
|
+
@late_comers << entity
|
110
|
+
@entities[entity][:initiative] = entity.initiative!(self)
|
111
|
+
end
|
112
|
+
|
113
|
+
return if position.nil?
|
114
|
+
return if @map.nil?
|
115
|
+
|
116
|
+
if position.is_a?(Array)
|
117
|
+
@map.place(*position, entity, token,
|
118
|
+
self)
|
119
|
+
else
|
120
|
+
@map.place_at_spawn_point(position, entity, token)
|
121
|
+
end
|
122
|
+
end
|
123
|
+
|
124
|
+
def in_battle?(entity)
|
125
|
+
@entities.key?(entity)
|
126
|
+
end
|
127
|
+
|
128
|
+
def move_for(entity)
|
129
|
+
@entities[entity][:controller].move_for(entity, self)
|
130
|
+
end
|
131
|
+
|
132
|
+
def controller_for(entity)
|
133
|
+
return nil unless @entities.key? entity
|
134
|
+
|
135
|
+
@entities[entity][:controller]
|
136
|
+
end
|
137
|
+
|
138
|
+
def roll_for(entity, die_type, number_of_times, description, advantage: false, disadvantage: false)
|
139
|
+
controller = if @entities[entity] && @entities[entity][:controller]
|
140
|
+
@entities[entity][:controller]
|
141
|
+
else
|
142
|
+
@standard_controller
|
143
|
+
end
|
144
|
+
rolls = controller.try(:roll_for, entity, die_type, number_of_times, description,
|
145
|
+
advantage: advantage, disadvantage: disadvantage)
|
146
|
+
return rolls if rolls
|
147
|
+
|
148
|
+
if advantage || disadvantage
|
149
|
+
number_of_times.times.map { [(1..die_type).to_a.sample, (1..die_type).to_a.sample] }
|
150
|
+
else
|
151
|
+
number_of_times.times.map { (1..die_type).to_a.sample }
|
152
|
+
end
|
153
|
+
end
|
154
|
+
|
155
|
+
# @param entity [Natural20::Entity]
|
156
|
+
# @return [Hash]
|
157
|
+
def entity_state_for(entity)
|
158
|
+
@entities[entity]
|
159
|
+
end
|
160
|
+
|
161
|
+
def entity_group_for(entity)
|
162
|
+
return :none unless @entities[entity]
|
163
|
+
|
164
|
+
@entities[entity][:group]
|
165
|
+
end
|
166
|
+
|
167
|
+
def dismiss_help_actions_for(source)
|
168
|
+
@entities.each do |_k, entity|
|
169
|
+
entity[:target_effect]&.delete(source) if %i[help help_ability_check].include?(entity[:target_effect][source])
|
170
|
+
end
|
171
|
+
end
|
172
|
+
|
173
|
+
def help_with?(target)
|
174
|
+
return @entities[target][:target_effect].values.include?(:help) if @entities[target]
|
175
|
+
|
176
|
+
false
|
177
|
+
end
|
178
|
+
|
179
|
+
def dismiss_help_for(target)
|
180
|
+
return unless @entities[target]
|
181
|
+
|
182
|
+
@entities[target][:target_effect].delete_if { |_k, v| v == :help }
|
183
|
+
end
|
184
|
+
|
185
|
+
def action(source, action_type, opts = {})
|
186
|
+
action = source.available_actions(@session, self).detect { |act| act.action_type == action_type }
|
187
|
+
opts[:battle] = self
|
188
|
+
return action.resolve(@session, @map, opts) if action
|
189
|
+
|
190
|
+
nil
|
191
|
+
end
|
192
|
+
|
193
|
+
def action!(action)
|
194
|
+
opts = {
|
195
|
+
battle: self
|
196
|
+
}
|
197
|
+
action.resolve(@session, @map, opts)
|
198
|
+
end
|
199
|
+
|
200
|
+
def compute_max_weapon_range(action, range = nil)
|
201
|
+
case action.action_type
|
202
|
+
when :help
|
203
|
+
5
|
204
|
+
when :attack
|
205
|
+
if action.npc_action
|
206
|
+
action.npc_action[:range_max].presence || action.npc_action[:range]
|
207
|
+
elsif action.using
|
208
|
+
weapon = session.load_weapon(action.using)
|
209
|
+
if action.thrown
|
210
|
+
weapon.dig(:thrown, :range_max) || weapon.dig(:thrown, :range) || weapon[:range]
|
211
|
+
else
|
212
|
+
weapon[:range_max].presence || weapon[:range]
|
213
|
+
end
|
214
|
+
end
|
215
|
+
else
|
216
|
+
range
|
217
|
+
end
|
218
|
+
end
|
219
|
+
|
220
|
+
def active_perception_for(entity)
|
221
|
+
@entities[entity][:active_perception] || 0
|
222
|
+
end
|
223
|
+
|
224
|
+
# Generates targets that make sense for a given action
|
225
|
+
# @param entity [Natural20::Entity]
|
226
|
+
# @param action [Natural20::Action]
|
227
|
+
# @param filter [String]
|
228
|
+
# @option target_types [Array<Symbol>]
|
229
|
+
# @return [Natural20::Entity]
|
230
|
+
def valid_targets_for(entity, action, target_types: [:enemies], range: nil, active_perception: nil, include_objects: false, filter: nil)
|
231
|
+
raise 'not an action' unless action.is_a?(Natural20::Action)
|
232
|
+
|
233
|
+
active_perception = active_perception.nil? ? active_perception_for(entity) : 0
|
234
|
+
target_types = target_types&.map(&:to_sym) || [:enemies]
|
235
|
+
entity_group = @entities[entity][:group]
|
236
|
+
attack_range = compute_max_weapon_range(action, range)
|
237
|
+
|
238
|
+
raise 'attack range cannot be nil' if attack_range.nil?
|
239
|
+
|
240
|
+
targets = @entities.map do |k, prop|
|
241
|
+
next if !target_types.include?(:self) && k == entity
|
242
|
+
next if !target_types.include?(:allies) && prop[:group] == entity_group && k != entity
|
243
|
+
next if !target_types.include?(:enemies) && opposing?(entity, k)
|
244
|
+
next if k.dead?
|
245
|
+
next if k.hp.nil?
|
246
|
+
next if !target_types.include?(:ignore_los) && !can_see?(entity, k, active_perception: active_perception)
|
247
|
+
next if @map.distance(k, entity) * @map.feet_per_grid > attack_range
|
248
|
+
next if filter && !k.eval_if(filter)
|
249
|
+
|
250
|
+
action.target = k
|
251
|
+
action.validate
|
252
|
+
next unless action.errors.empty?
|
253
|
+
|
254
|
+
k
|
255
|
+
end.compact
|
256
|
+
|
257
|
+
if include_objects
|
258
|
+
targets += @map.interactable_objects.map do |object, _position|
|
259
|
+
next if object.dead?
|
260
|
+
next if !target_types.include?(:ignore_los) && !can_see?(entity, object, active_perception: active_perception)
|
261
|
+
next if @map.distance(object, entity) * @map.feet_per_grid > attack_range
|
262
|
+
next if filter && !k.eval_if(filter)
|
263
|
+
|
264
|
+
object
|
265
|
+
end.compact
|
266
|
+
end
|
267
|
+
|
268
|
+
targets
|
269
|
+
end
|
270
|
+
|
271
|
+
# Determines if an entity can see someone in battle
|
272
|
+
# @param entity1 [Natural20::Entity] observer
|
273
|
+
# @param entity2 [Natural20::Entity] entity being observed
|
274
|
+
# @return [Boolean]
|
275
|
+
def can_see?(entity1, entity2, active_perception: 0, entity_1_pos: nil, entity_2_pos: nil)
|
276
|
+
return true if entity1 == entity2
|
277
|
+
return false unless @map.can_see?(entity1, entity2, entity_1_pos: entity_1_pos, entity_2_pos: entity_2_pos)
|
278
|
+
return true unless entity2.hiding?(self)
|
279
|
+
|
280
|
+
cover_value = @map.cover_calculation(@map, entity1, entity2, entity_1_pos: entity_1_pos,
|
281
|
+
naturally_stealthy: entity2.class_feature?('naturally_stealthy'))
|
282
|
+
if cover_value.positive?
|
283
|
+
entity_2_state = entity_state_for(entity2)
|
284
|
+
return false if entity_2_state[:stealth] > [active_perception, entity1.passive_perception].max
|
285
|
+
end
|
286
|
+
|
287
|
+
true
|
288
|
+
end
|
289
|
+
|
290
|
+
# Retruns opponents of entity
|
291
|
+
# @param entity [Natural20::Entity] target entity
|
292
|
+
# @return [Array<Natrual20::Entity>]
|
293
|
+
def opponents_of?(entity)
|
294
|
+
(@entities.keys + @late_comers).reject(&:dead?).select do |k|
|
295
|
+
opposing?(k, entity)
|
296
|
+
end
|
297
|
+
end
|
298
|
+
|
299
|
+
# Determines if two entities are opponents of each other
|
300
|
+
# @param entity1 [Natural20::Entity]
|
301
|
+
# @param entity2 [Natural20::Entity]
|
302
|
+
# @return [Boolean]
|
303
|
+
def opposing?(entity1, entity2)
|
304
|
+
source_state1 = entity_state_for(entity1)
|
305
|
+
source_state2 = entity_state_for(entity2)
|
306
|
+
return false if source_state1.nil? || source_state2.nil?
|
307
|
+
|
308
|
+
source_group1 = source_state1[:group]
|
309
|
+
source_group2 = source_state2[:group]
|
310
|
+
|
311
|
+
@opposing_groups[source_group1]&.include?(source_group2)
|
312
|
+
end
|
313
|
+
|
314
|
+
# Determines if two entities are allies of each other
|
315
|
+
# @param entity1 [Natural20::Entity]
|
316
|
+
# @param entity2 [Natural20::Entity]
|
317
|
+
# @return [Boolean]
|
318
|
+
def allies?(entity1, entity2)
|
319
|
+
source_state1 = entity_state_for(entity1)
|
320
|
+
source_state2 = entity_state_for(entity2)
|
321
|
+
return false if source_state1.nil? || source_state2.nil?
|
322
|
+
|
323
|
+
source_group1 = source_state1[:group]
|
324
|
+
source_group2 = source_state2[:group]
|
325
|
+
|
326
|
+
source_group1 == source_group2
|
327
|
+
end
|
328
|
+
|
329
|
+
# Checks if this entity is controlled by AI or Person
|
330
|
+
# @param entity [Natural20::Entity]
|
331
|
+
# @return [Boolean]
|
332
|
+
def has_controller_for?(entity)
|
333
|
+
raise 'unknown entity in battle' unless @entities.key?(entity)
|
334
|
+
|
335
|
+
@entities[entity][:controller] != :manual
|
336
|
+
end
|
337
|
+
|
338
|
+
# consume action resource and return if something changed
|
339
|
+
def consume!(entity, resource, qty)
|
340
|
+
current_qty = entity_state_for(entity)[resource.to_sym]
|
341
|
+
new_qty = [0, current_qty - qty].max
|
342
|
+
entity_state_for(entity)[resource.to_sym] = new_qty
|
343
|
+
|
344
|
+
current_qty != new_qty
|
345
|
+
end
|
346
|
+
|
347
|
+
# @param combat_order [Array] If specified will use this array as the initiative order
|
348
|
+
def start(combat_order = nil)
|
349
|
+
if combat_order
|
350
|
+
@combat_order = combat_order
|
351
|
+
return
|
352
|
+
end
|
353
|
+
|
354
|
+
# roll for initiative
|
355
|
+
@combat_order = @entities.map do |entity, v|
|
356
|
+
next if entity.dead?
|
357
|
+
|
358
|
+
v[:initiative] = entity.initiative!(self)
|
359
|
+
|
360
|
+
entity
|
361
|
+
end.compact
|
362
|
+
|
363
|
+
@started = true
|
364
|
+
@current_turn_index = 0
|
365
|
+
@combat_order = @combat_order.sort_by { |a| @entities[a][:initiative] || a.name }.reverse
|
366
|
+
end
|
367
|
+
|
368
|
+
# @return [Natural20::Entity]
|
369
|
+
def current_turn
|
370
|
+
@combat_order[@current_turn_index]
|
371
|
+
end
|
372
|
+
|
373
|
+
def check_combat
|
374
|
+
if !@started && !battle_ends?
|
375
|
+
start
|
376
|
+
Natural20::EventManager.received_event(source: self, event: :start_of_combat, target: current_turn,
|
377
|
+
combat_order: @combat_order.map { |e| [e, @entities[e][:initiative]] })
|
378
|
+
return true
|
379
|
+
end
|
380
|
+
false
|
381
|
+
end
|
382
|
+
|
383
|
+
def while_active(max_rounds = nil, &block)
|
384
|
+
loop do
|
385
|
+
Natural20::EventManager.received_event(source: self, event: :start_of_round)
|
386
|
+
|
387
|
+
current_turn.death_saving_throw!(self) if current_turn.unconscious? && !current_turn.stable?
|
388
|
+
|
389
|
+
if current_turn.conscious?
|
390
|
+
current_turn.reset_turn!(self)
|
391
|
+
next if block.call(current_turn)
|
392
|
+
end
|
393
|
+
|
394
|
+
return :tpk if tpk?
|
395
|
+
|
396
|
+
trigger_event!(:end_of_round, self, target: current_turn)
|
397
|
+
|
398
|
+
if @started && battle_ends?
|
399
|
+
Natural20::EventManager.received_event(source: self, event: :end_of_combat)
|
400
|
+
@started = false
|
401
|
+
end
|
402
|
+
|
403
|
+
@current_turn_index += 1
|
404
|
+
next unless @current_turn_index >= @combat_order.length
|
405
|
+
|
406
|
+
@current_turn_index = 0
|
407
|
+
@round += 1
|
408
|
+
|
409
|
+
# top of the round
|
410
|
+
unless @late_comers.empty?
|
411
|
+
@combat_order += @late_comers
|
412
|
+
@late_comers.clear
|
413
|
+
@combat_order = @combat_order.sort_by { |a| @entities[a][:initiative] || a.name }.reverse
|
414
|
+
end
|
415
|
+
session.increment_game_time!
|
416
|
+
|
417
|
+
Natural20::EventManager.received_event({ source: self, event: :top_of_the_round, round: @round,
|
418
|
+
target: current_turn })
|
419
|
+
|
420
|
+
return if !max_rounds.nil? && @round > max_rounds
|
421
|
+
end
|
422
|
+
end
|
423
|
+
|
424
|
+
# Determines if there is a conscious enemey within melee range
|
425
|
+
# @param source [Natural20::Entity]
|
426
|
+
# @return [Boolean]
|
427
|
+
def enemy_in_melee_range?(source, exclude = [], source_pos: nil)
|
428
|
+
objects_around_me = map.look(source)
|
429
|
+
|
430
|
+
objects_around_me.detect do |object, _|
|
431
|
+
next if exclude.include?(object)
|
432
|
+
|
433
|
+
state = entity_state_for(object)
|
434
|
+
next unless state
|
435
|
+
next unless object.conscious?
|
436
|
+
|
437
|
+
return true if opposing?(source, object) && (map.distance(source,
|
438
|
+
object, entity_1_pos: source_pos) <= (object.melee_distance / map.feet_per_grid))
|
439
|
+
end
|
440
|
+
|
441
|
+
false
|
442
|
+
end
|
443
|
+
|
444
|
+
# Determines if there is a conscious ally within melee range of target
|
445
|
+
# @param source [Natural20::Entity]
|
446
|
+
# @return [Boolean]
|
447
|
+
def ally_within_enemey_melee_range?(source, target, exclude = [], source_pos: nil)
|
448
|
+
objects_around_me = map.look(target)
|
449
|
+
|
450
|
+
objects_around_me.detect do |object, _|
|
451
|
+
next if exclude.include?(object)
|
452
|
+
next if object == source
|
453
|
+
|
454
|
+
state = entity_state_for(object)
|
455
|
+
next unless state
|
456
|
+
next unless object.conscious?
|
457
|
+
|
458
|
+
return true if allies?(source, object) && (map.distance(target,
|
459
|
+
object, entity_1_pos: source_pos) <= (object.melee_distance / map.feet_per_grid))
|
460
|
+
end
|
461
|
+
|
462
|
+
false
|
463
|
+
end
|
464
|
+
|
465
|
+
def ongoing?
|
466
|
+
@started
|
467
|
+
end
|
468
|
+
|
469
|
+
def combat?
|
470
|
+
ongoing?
|
471
|
+
end
|
472
|
+
|
473
|
+
def tpk?
|
474
|
+
@current_party && !@current_party.detect(&:conscious?)
|
475
|
+
end
|
476
|
+
|
477
|
+
def battle_ends?
|
478
|
+
groups_present = @entities.keys.reject do |a|
|
479
|
+
a.dead? || a.unconscious?
|
480
|
+
end.map { |e| @entities[e][:group] }.uniq
|
481
|
+
groups_present.each do |g|
|
482
|
+
groups_present.each do |h|
|
483
|
+
next if g == h
|
484
|
+
raise "warning unknown group #{g}" unless @opposing_groups[g]
|
485
|
+
return false if @opposing_groups[g].include?(h)
|
486
|
+
end
|
487
|
+
end
|
488
|
+
true
|
489
|
+
end
|
490
|
+
|
491
|
+
def trigger_opportunity_attack(entity, target, cur_x, cur_y)
|
492
|
+
event = {
|
493
|
+
target: target,
|
494
|
+
position: [cur_x, cur_y]
|
495
|
+
}
|
496
|
+
action = entity.trigger_event(:opportunity_attack, self, @session, @map, event)
|
497
|
+
if action
|
498
|
+
action.resolve(@session, @map, battle: self)
|
499
|
+
commit(action)
|
500
|
+
end
|
501
|
+
end
|
502
|
+
|
503
|
+
def commit(action)
|
504
|
+
return if action.nil?
|
505
|
+
|
506
|
+
# check_action_serialization(action)
|
507
|
+
action.apply!(self)
|
508
|
+
|
509
|
+
case action.action_type
|
510
|
+
when :move
|
511
|
+
trigger_event!(:movement, action.source, move_path: action.move_path)
|
512
|
+
when :interact
|
513
|
+
trigger_event!(:interact, action)
|
514
|
+
end
|
515
|
+
@battle_log << action
|
516
|
+
end
|
517
|
+
|
518
|
+
def trigger_event!(event, source, opt = {})
|
519
|
+
@battle_field_events[event.to_sym]&.each do |object, handler|
|
520
|
+
object.send(handler, self, source, opt.merge(ui_controller: controller_for(source)))
|
521
|
+
end
|
522
|
+
@map.activate_map_triggers(event, source, opt.merge(ui_controller: controller_for(source)))
|
523
|
+
end
|
524
|
+
|
525
|
+
protected
|
526
|
+
|
527
|
+
def t(key, options = {})
|
528
|
+
I18n.t(key, options)
|
529
|
+
end
|
530
|
+
|
531
|
+
def check_action_serialization(action)
|
532
|
+
action.result.each do |r|
|
533
|
+
r.each do |k, v|
|
534
|
+
next if v.is_a?(String)
|
535
|
+
next if v.is_a?(Integer)
|
536
|
+
next if [true, false].include?(v)
|
537
|
+
next if v.is_a?(Numeric)
|
538
|
+
|
539
|
+
raise "#{action.action_type} value #{k} -> #{v} not serializable!"
|
540
|
+
end
|
541
|
+
end
|
542
|
+
end
|
543
|
+
end
|
544
|
+
end
|