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,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
|