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,843 @@
|
|
1
|
+
# typed: false
|
2
|
+
module Natural20
|
3
|
+
class BattleMap
|
4
|
+
include Natural20::Cover
|
5
|
+
include Natural20::MovementHelper
|
6
|
+
|
7
|
+
attr_reader :properties, :base_map, :spawn_points, :tokens, :entities, :size, :interactable_objects, :session,
|
8
|
+
:unaware_npcs, :size, :area_triggers, :feet_per_grid
|
9
|
+
|
10
|
+
# @param session [Natural20::Session] The current game session
|
11
|
+
# @param map_file [String] Path to map file
|
12
|
+
def initialize(session, map_file)
|
13
|
+
@session = session
|
14
|
+
@map_file = map_file
|
15
|
+
@spawn_points = {}
|
16
|
+
@entities = {}
|
17
|
+
@area_triggers = {}
|
18
|
+
@interactable_objects = {}
|
19
|
+
@unaware_npcs = []
|
20
|
+
|
21
|
+
@properties = YAML.load_file(File.join(session.root_path, "#{map_file}.yml")).deep_symbolize_keys!
|
22
|
+
|
23
|
+
@feet_per_grid = @properties[:grid_size] || 5
|
24
|
+
|
25
|
+
# terrain layer
|
26
|
+
@base_map = @properties.dig(:map, :base).map do |lines|
|
27
|
+
lines.each_char.map.to_a
|
28
|
+
end.transpose
|
29
|
+
@size = [@base_map.size, @base_map.first.size]
|
30
|
+
|
31
|
+
# terrain layer 2
|
32
|
+
@base_map_1 = if @properties.dig(:map, :base_1).blank?
|
33
|
+
@size[0].times.map do
|
34
|
+
@size[1].times.map { nil }
|
35
|
+
end
|
36
|
+
else
|
37
|
+
@properties.dig(:map, :base_1).map do |lines|
|
38
|
+
lines.each_char.map { |c| c == '.' ? nil : c }
|
39
|
+
end.transpose
|
40
|
+
end
|
41
|
+
|
42
|
+
# meta layer
|
43
|
+
if @properties.dig(:map, :meta)
|
44
|
+
@meta_map = @properties.dig(:map, :meta).map do |lines|
|
45
|
+
lines.each_char.map.to_a
|
46
|
+
end.transpose
|
47
|
+
end
|
48
|
+
|
49
|
+
@legend = @properties[:legend] || {}
|
50
|
+
|
51
|
+
@tokens = @size[0].times.map do
|
52
|
+
@size[1].times.map { nil }
|
53
|
+
end
|
54
|
+
|
55
|
+
@area_notes = @size[0].times.map do
|
56
|
+
@size[1].times.map { nil }
|
57
|
+
end
|
58
|
+
|
59
|
+
@objects = @size[0].times.map do
|
60
|
+
@size[1].times.map do
|
61
|
+
[]
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
@properties[:notes]&.each_with_index do |note, index|
|
66
|
+
note_object = OpenStruct.new(note.merge(note_id: note[:id] || index))
|
67
|
+
note_positions = case note_object.type
|
68
|
+
when 'point'
|
69
|
+
note[:positions]
|
70
|
+
when 'rectangle'
|
71
|
+
note[:positions].map do |position|
|
72
|
+
left_x, left_y, right_x, right_y = position
|
73
|
+
|
74
|
+
(left_x..right_x).map do |pos_x|
|
75
|
+
(left_y..right_y).map do |pos_y|
|
76
|
+
[pos_x, pos_y]
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end.flatten(2).uniq
|
80
|
+
else
|
81
|
+
raise "invalid note type #{note_object.type}"
|
82
|
+
end
|
83
|
+
|
84
|
+
note_positions.each do |position|
|
85
|
+
pos_x, pos_y = position
|
86
|
+
@area_notes[pos_x][pos_y] ||= []
|
87
|
+
@area_notes[pos_x][pos_y] << note_object unless @area_notes[pos_x][pos_y].include?(note_object)
|
88
|
+
end
|
89
|
+
end
|
90
|
+
|
91
|
+
@triggers = (@properties[:triggers] || {}).deep_symbolize_keys
|
92
|
+
|
93
|
+
@light_builder = Natural20::StaticLightBuilder.new(self)
|
94
|
+
|
95
|
+
setup_objects
|
96
|
+
setup_npcs
|
97
|
+
compute_lights # compute static lights
|
98
|
+
end
|
99
|
+
|
100
|
+
def activate_map_triggers(trigger_type, source, opt = {})
|
101
|
+
return unless @triggers.key?(trigger_type.to_sym)
|
102
|
+
|
103
|
+
@triggers[trigger_type.to_sym].each do |trigger|
|
104
|
+
next if trigger[:if] && !source&.eval_if(trigger[:if], opt)
|
105
|
+
|
106
|
+
case trigger[:type]
|
107
|
+
when 'message'
|
108
|
+
opt[:ui_controller]&.show_message(trigger[:content])
|
109
|
+
when 'battle_end'
|
110
|
+
return :battle_end
|
111
|
+
else
|
112
|
+
raise "unknown trigger type #{trigger[:type]}"
|
113
|
+
end
|
114
|
+
end
|
115
|
+
end
|
116
|
+
|
117
|
+
def wall?(pos_x, pos_y)
|
118
|
+
return true if pos_x.negative? || pos_y.negative?
|
119
|
+
return true if pos_x >= size[0] || pos_y >= size[1]
|
120
|
+
|
121
|
+
return true if object_at(pos_x, pos_y)&.wall?
|
122
|
+
|
123
|
+
false
|
124
|
+
end
|
125
|
+
|
126
|
+
# Get object at map location
|
127
|
+
# @param pos_x [Integer]
|
128
|
+
# @param pos_y [Integer]
|
129
|
+
# @param reveal_concealed [Boolean]
|
130
|
+
# @return [ItemLibrary::Object]
|
131
|
+
def object_at(pos_x, pos_y, reveal_concealed: false)
|
132
|
+
@objects[pos_x][pos_y]&.detect { |o| reveal_concealed || !o.concealed? }
|
133
|
+
end
|
134
|
+
|
135
|
+
# Get object at map location
|
136
|
+
# @param pos_x [Integer]
|
137
|
+
# @param pos_y [Integer]
|
138
|
+
# @return [ItemLibrary::Object]
|
139
|
+
def objects_at(pos_x, pos_y)
|
140
|
+
@objects[pos_x][pos_y]
|
141
|
+
end
|
142
|
+
|
143
|
+
# Lists interactable objects near an entity
|
144
|
+
# @param entity [Entity]
|
145
|
+
# @param battle [Natural20::Battle]
|
146
|
+
# @return [Array]
|
147
|
+
def objects_near(entity, battle = nil)
|
148
|
+
target_squares = entity.melee_squares(self)
|
149
|
+
target_squares += battle.map.entity_squares(entity) if battle&.map
|
150
|
+
objects = []
|
151
|
+
|
152
|
+
available_objects = target_squares.map do |square|
|
153
|
+
objects_at(*square)
|
154
|
+
end.flatten.compact
|
155
|
+
|
156
|
+
available_objects.each do |object|
|
157
|
+
objects << object unless object.available_interactions(entity, battle).empty?
|
158
|
+
end
|
159
|
+
|
160
|
+
@entities.each do |object, position|
|
161
|
+
next if object == entity
|
162
|
+
|
163
|
+
objects << object if !object.available_interactions(entity, battle).empty? && target_squares.include?(position)
|
164
|
+
end
|
165
|
+
objects
|
166
|
+
end
|
167
|
+
|
168
|
+
def items_on_the_ground(entity)
|
169
|
+
target_squares = entity.melee_squares(self)
|
170
|
+
target_squares += entity_squares(entity)
|
171
|
+
|
172
|
+
available_objects = target_squares.map do |square|
|
173
|
+
objects_at(*square)
|
174
|
+
end.flatten.compact
|
175
|
+
|
176
|
+
ground_objects = available_objects.select { |obj| obj.is_a?(ItemLibrary::Ground) }
|
177
|
+
ground_objects.map do |obj|
|
178
|
+
items = obj.inventory.select { |o| o.qty.positive? }
|
179
|
+
next if items.empty?
|
180
|
+
|
181
|
+
[obj, items]
|
182
|
+
end.compact
|
183
|
+
end
|
184
|
+
|
185
|
+
def ground_at(pos_x, pos_y)
|
186
|
+
available_objects = objects_at(pos_x, pos_y).compact
|
187
|
+
available_objects.detect { |obj| obj.is_a?(ItemLibrary::Ground) }
|
188
|
+
end
|
189
|
+
|
190
|
+
# Place token here if it is not already present on the board
|
191
|
+
# @param pos_x [Integer]
|
192
|
+
# @param pos_y [Integer]
|
193
|
+
# @param entity [Natural20::Entity]
|
194
|
+
# @param token [String]
|
195
|
+
# @param battle [Natural20::Battle]
|
196
|
+
def place(pos_x, pos_y, entity, token = nil, battle = nil)
|
197
|
+
raise 'entity param is required' if entity.nil?
|
198
|
+
|
199
|
+
entity_data = { entity: entity, token: token || entity.name&.first }
|
200
|
+
@tokens[pos_x][pos_y] = entity_data
|
201
|
+
@entities[entity] = [pos_x, pos_y]
|
202
|
+
|
203
|
+
source_token_size = if requires_squeeze?(entity, pos_x, pos_y, self, battle)
|
204
|
+
entity.squeezed!
|
205
|
+
[entity.token_size - 1, 1].max
|
206
|
+
else
|
207
|
+
entity.token_size
|
208
|
+
end
|
209
|
+
|
210
|
+
(0...source_token_size).each do |ofs_x|
|
211
|
+
(0...source_token_size).each do |ofs_y|
|
212
|
+
@tokens[pos_x + ofs_x][pos_y + ofs_y] = entity_data
|
213
|
+
end
|
214
|
+
end
|
215
|
+
end
|
216
|
+
|
217
|
+
def place_at_spawn_point(position, entity, token = nil, battle = nil)
|
218
|
+
unless @spawn_points.key?(position.to_s)
|
219
|
+
raise "unknown spawn position #{position}. should be any of #{@spawn_points.keys.join(',')}"
|
220
|
+
end
|
221
|
+
|
222
|
+
pos_x, pos_y = @spawn_points[position.to_s][:location]
|
223
|
+
place(pos_x, pos_y, entity, token, battle)
|
224
|
+
EventManager.logger.debug "place #{entity.name} at #{pos_x}, #{pos_y}"
|
225
|
+
end
|
226
|
+
|
227
|
+
# Computes the distance between two entities
|
228
|
+
# @param entity1 [Natural20::Entity]
|
229
|
+
# @param entity2 [Natural20::Entity]
|
230
|
+
# @result [Integer]
|
231
|
+
def distance(entity1, entity2, entity_1_pos: nil)
|
232
|
+
raise 'entity 1 param cannot be nil' if entity1.nil?
|
233
|
+
raise 'entity 2 param cannot be nil' if entity2.nil?
|
234
|
+
|
235
|
+
# entity 1 squares
|
236
|
+
entity_1_sq = entity_1_pos ? entity_squares_at_pos(entity1, *entity_1_pos) : entity_squares(entity1)
|
237
|
+
entity_2_sq = entity_squares(entity2)
|
238
|
+
|
239
|
+
entity_1_sq.map do |ent1_pos|
|
240
|
+
entity_2_sq.map do |ent2_pos|
|
241
|
+
pos1_x, pos1_y = ent1_pos
|
242
|
+
pos2_x, pos2_y = ent2_pos
|
243
|
+
Math.sqrt((pos1_x - pos2_x)**2 + (pos1_y - pos2_y)**2).floor
|
244
|
+
end
|
245
|
+
end.flatten.min
|
246
|
+
end
|
247
|
+
|
248
|
+
# Get all the location of the squares occupied by said entity (e.g. for large, huge creatures)
|
249
|
+
# @param entity [Natural20::Entity]
|
250
|
+
# @return [Array]
|
251
|
+
def entity_squares(entity, squeeze = false)
|
252
|
+
raise 'invalid entity' unless entity
|
253
|
+
|
254
|
+
pos1_x, pos1_y = entity_or_object_pos(entity)
|
255
|
+
entity_1_squares = []
|
256
|
+
token_size = if squeeze
|
257
|
+
[entity.token_size - 1, 1].max
|
258
|
+
else
|
259
|
+
entity.token_size
|
260
|
+
end
|
261
|
+
(0...token_size).each do |ofs_x|
|
262
|
+
(0...token_size).each do |ofs_y|
|
263
|
+
next if (pos1_x + ofs_x >= size[0]) || (pos1_y + ofs_y >= size[1])
|
264
|
+
|
265
|
+
entity_1_squares << [pos1_x + ofs_x, pos1_y + ofs_y]
|
266
|
+
end
|
267
|
+
end
|
268
|
+
entity_1_squares
|
269
|
+
end
|
270
|
+
|
271
|
+
# Get all the location of the squares occupied by said entity (e.g. for large, huge creatures)
|
272
|
+
# and use specified entity location instead of current location on the map
|
273
|
+
# @param entity [Natural20::Entity]
|
274
|
+
# @param pos1_x [Integer]
|
275
|
+
# @param pos1_y [Integer]
|
276
|
+
# @return [Array]
|
277
|
+
def entity_squares_at_pos(entity, pos1_x, pos1_y, squeeze = false)
|
278
|
+
entity_1_squares = []
|
279
|
+
token_size = if squeeze
|
280
|
+
[entity.token_size - 1, 1].max
|
281
|
+
else
|
282
|
+
entity.token_size
|
283
|
+
end
|
284
|
+
(0...token_size).each do |ofs_x|
|
285
|
+
(0...token_size).each do |ofs_y|
|
286
|
+
next if (pos1_x + ofs_x >= size[0]) || (pos1_y + ofs_y >= size[1])
|
287
|
+
|
288
|
+
entity_1_squares << [pos1_x + ofs_x, pos1_y + ofs_y]
|
289
|
+
end
|
290
|
+
end
|
291
|
+
entity_1_squares
|
292
|
+
end
|
293
|
+
|
294
|
+
# Natural20::Entity to look around
|
295
|
+
# @param entity [Natural20::Entity] The entity to look around his line of sight
|
296
|
+
# @return [Hash] entities in line of sight
|
297
|
+
def look(entity, distance = nil)
|
298
|
+
@entities.map do |k, v|
|
299
|
+
next if k == entity
|
300
|
+
|
301
|
+
pos1_x, pos1_y = v
|
302
|
+
next unless can_see?(entity, k, distance: distance)
|
303
|
+
|
304
|
+
[k, [pos1_x, pos1_y]]
|
305
|
+
end.compact.to_h
|
306
|
+
end
|
307
|
+
|
308
|
+
# Compute if entity is in line of sight
|
309
|
+
# @param entity [Natural20::Entity]
|
310
|
+
# @param pos2_x [Integer]
|
311
|
+
# @param pos2_y [Integer]
|
312
|
+
# @param distance [Integer]
|
313
|
+
# @return [TrueClass, FalseClass]
|
314
|
+
def line_of_sight_for?(entity, pos2_x, pos2_y, distance = nil)
|
315
|
+
raise 'cannot find entity' if @entities[entity].nil?
|
316
|
+
|
317
|
+
pos1_x, pos1_y = @entities[entity]
|
318
|
+
line_of_sight?(pos1_x, pos1_y, pos2_x, pos2_y, distance)
|
319
|
+
end
|
320
|
+
|
321
|
+
# Test to see if an entity can see a square
|
322
|
+
# @param entity [Natural20::Entity]
|
323
|
+
# @param pos2_x [Integer]
|
324
|
+
# @param pos2_y [Integer]
|
325
|
+
# @param allow_dark_vision [Boolean] Allow darkvision
|
326
|
+
# @return [Boolean]
|
327
|
+
def can_see_square?(entity, pos2_x, pos2_y, allow_dark_vision: true)
|
328
|
+
has_line_of_sight = false
|
329
|
+
max_illumniation = 0.0
|
330
|
+
sighting_distance = nil
|
331
|
+
|
332
|
+
entity_1_squares = entity_squares(entity)
|
333
|
+
entity_1_squares.each do |pos1|
|
334
|
+
pos1_x, pos1_y = pos1
|
335
|
+
return true if [pos1_x, pos1_y] == [pos2_x, pos2_y]
|
336
|
+
next unless line_of_sight?(pos1_x, pos1_y, pos2_x, pos2_y, nil, false)
|
337
|
+
|
338
|
+
location_illumnination = light_at(pos2_x, pos2_y)
|
339
|
+
max_illumniation = [location_illumnination, max_illumniation].max
|
340
|
+
sighting_distance = Math.sqrt((pos1_x - pos2_x)**2 + (pos1_y - pos2_y)**2).floor
|
341
|
+
has_line_of_sight = true
|
342
|
+
end
|
343
|
+
|
344
|
+
if has_line_of_sight && max_illumniation < 0.5
|
345
|
+
return allow_dark_vision && entity.darkvision?(sighting_distance * @feet_per_grid)
|
346
|
+
end
|
347
|
+
|
348
|
+
has_line_of_sight
|
349
|
+
end
|
350
|
+
|
351
|
+
# Checks if an entity can see another
|
352
|
+
# @param entity [Natural20::Entity] entity looking
|
353
|
+
# @param entity2 [Natural20::Entity] entity being looked at
|
354
|
+
# @param distance [Integer]
|
355
|
+
# @param entity_2_pos [Array] position override for entity2
|
356
|
+
# @param allow_dark_vision [Boolean] Allow darkvision
|
357
|
+
# @return [Boolean]
|
358
|
+
def can_see?(entity, entity2, distance: nil, entity_1_pos: nil, entity_2_pos: nil, allow_dark_vision: true, active_perception: 0, active_perception_disadvantage: 0)
|
359
|
+
raise 'invalid entity passed' if @entities[entity].nil? && @interactable_objects[entity].nil?
|
360
|
+
|
361
|
+
entity_1_squares = entity_1_pos ? entity_squares_at_pos(entity, *entity_1_pos) : entity_squares(entity)
|
362
|
+
entity_2_squares = entity_2_pos ? entity_squares_at_pos(entity2, *entity_2_pos) : entity_squares(entity2)
|
363
|
+
|
364
|
+
has_line_of_sight = false
|
365
|
+
max_illumniation = 0.0
|
366
|
+
sighting_distance = nil
|
367
|
+
|
368
|
+
entity_1_squares.each do |pos1|
|
369
|
+
entity_2_squares.each do |pos2|
|
370
|
+
pos1_x, pos1_y = pos1
|
371
|
+
pos2_x, pos2_y = pos2
|
372
|
+
next if pos1_x >= size[0] || pos1_x.negative? || pos1_y >= size[1] || pos1_y.negative?
|
373
|
+
next if pos2_x >= size[0] || pos2_x.negative? || pos2_y >= size[1] || pos2_y.negative?
|
374
|
+
next unless line_of_sight?(pos1_x, pos1_y, pos2_x, pos2_y, distance)
|
375
|
+
|
376
|
+
location_illumnination = light_at(pos2_x, pos2_y)
|
377
|
+
max_illumniation = [location_illumnination, max_illumniation].max
|
378
|
+
sighting_distance = Math.sqrt((pos1_x - pos2_x)**2 + (pos1_y - pos2_y)**2).floor
|
379
|
+
has_line_of_sight = true
|
380
|
+
end
|
381
|
+
end
|
382
|
+
|
383
|
+
if has_line_of_sight && max_illumniation < 0.5
|
384
|
+
return allow_dark_vision && entity.darkvision?(sighting_distance * @feet_per_grid)
|
385
|
+
end
|
386
|
+
|
387
|
+
has_line_of_sight
|
388
|
+
end
|
389
|
+
|
390
|
+
def light_at(pos_x, pos_y)
|
391
|
+
if @light_map
|
392
|
+
@light_map[pos_x][pos_y] + @light_builder.light_at(pos_x, pos_y)
|
393
|
+
else
|
394
|
+
@light_builder.light_at(pos_x, pos_y)
|
395
|
+
end
|
396
|
+
end
|
397
|
+
|
398
|
+
def position_of(entity)
|
399
|
+
entity.is_a?(ItemLibrary::Object) ? interactable_objects[entity] : @entities[entity]
|
400
|
+
end
|
401
|
+
|
402
|
+
# Get entity at map location
|
403
|
+
# @param pos_x [Integer]
|
404
|
+
# @param pos_y [Integer]
|
405
|
+
# @return [Natural20::Entity]
|
406
|
+
def entity_at(pos_x, pos_y)
|
407
|
+
entity_data = @tokens[pos_x][pos_y]
|
408
|
+
return nil if entity_data.nil?
|
409
|
+
|
410
|
+
entity_data[:entity]
|
411
|
+
end
|
412
|
+
|
413
|
+
# Get entity or object at map location
|
414
|
+
# @param pos_x [Integer]
|
415
|
+
# @param pos_y [Integer]
|
416
|
+
# @param reveal_concealed [Boolean] Included concealed objects
|
417
|
+
# @return [Array<Nautral20::Entity>]
|
418
|
+
def thing_at(pos_x, pos_y, reveal_concealed: false)
|
419
|
+
things = []
|
420
|
+
things << entity_at(pos_x, pos_y)
|
421
|
+
things << object_at(pos_x, pos_y, reveal_concealed: reveal_concealed)
|
422
|
+
things.compact
|
423
|
+
end
|
424
|
+
|
425
|
+
# Moves an entity to a specified location on the board
|
426
|
+
# @param entity [Natural20::Entity]
|
427
|
+
# @param pos_x [Integer]
|
428
|
+
# @param pos_y [Integer]
|
429
|
+
# @param battle [Natural20::Battle]
|
430
|
+
def move_to!(entity, pos_x, pos_y, battle)
|
431
|
+
cur_x, cur_y = @entities[entity]
|
432
|
+
|
433
|
+
entity_data = @tokens[cur_x][cur_y]
|
434
|
+
|
435
|
+
source_token_size = if requires_squeeze?(entity, cur_x, cur_y, self, battle)
|
436
|
+
[entity.token_size - 1, 1].max
|
437
|
+
else
|
438
|
+
entity.token_size
|
439
|
+
end
|
440
|
+
|
441
|
+
destination_token_size = if requires_squeeze?(entity, pos_x, pos_y, self, battle)
|
442
|
+
entity.squeezed!
|
443
|
+
[entity.token_size - 1, 1].max
|
444
|
+
else
|
445
|
+
entity.unsqueeze
|
446
|
+
entity.token_size
|
447
|
+
end
|
448
|
+
|
449
|
+
(0...source_token_size).each do |ofs_x|
|
450
|
+
(0...source_token_size).each do |ofs_y|
|
451
|
+
@tokens[cur_x + ofs_x][cur_y + ofs_y] = nil
|
452
|
+
end
|
453
|
+
end
|
454
|
+
|
455
|
+
(0...destination_token_size).each do |ofs_x|
|
456
|
+
(0...destination_token_size).each do |ofs_y|
|
457
|
+
@tokens[pos_x + ofs_x][pos_y + ofs_y] = entity_data
|
458
|
+
end
|
459
|
+
end
|
460
|
+
|
461
|
+
@entities[entity] = [pos_x, pos_y]
|
462
|
+
end
|
463
|
+
|
464
|
+
def valid_position?(pos_x, pos_y)
|
465
|
+
return false if pos_x >= @base_map.size || pos_x.negative? || pos_y >= @base_map[0].size || pos_y.negative?
|
466
|
+
|
467
|
+
return false if @base_map[pos_x][pos_y] == '#'
|
468
|
+
return false unless @tokens[pos_x][pos_y].nil?
|
469
|
+
|
470
|
+
true
|
471
|
+
end
|
472
|
+
|
473
|
+
# @param entity [Natural20::Entity]
|
474
|
+
# @param path [Array]
|
475
|
+
# @param battle [Natural20::Battle]
|
476
|
+
# @param manual_jump [Array]
|
477
|
+
# @return [Natural20::MovementHelper::Movement]
|
478
|
+
def movement_cost(entity, path, battle = nil, manual_jump = [])
|
479
|
+
return Natural20::MovementHelper::Movement.empty if path.empty?
|
480
|
+
|
481
|
+
budget = entity.available_movement(battle) / @feet_per_grid
|
482
|
+
compute_actual_moves(entity, path, self, battle, budget, test_placement: false,
|
483
|
+
manual_jump: manual_jump)
|
484
|
+
end
|
485
|
+
|
486
|
+
# Describes if terrain is passable or not
|
487
|
+
# @param entity [Natural20::Entity]
|
488
|
+
# @param pos_x [Integer]
|
489
|
+
# @param pos_y [Integer]
|
490
|
+
# @param battle [Natural20::Battle]
|
491
|
+
# @param allow_squeeze [Boolean] Allow entity to squeeze inside a space (PHB )
|
492
|
+
# @return [Boolean]
|
493
|
+
def passable?(entity, pos_x, pos_y, battle = nil, allow_squeeze = true)
|
494
|
+
effective_token_size = if allow_squeeze
|
495
|
+
[entity.token_size - 1, 1].max
|
496
|
+
else
|
497
|
+
entity.token_size
|
498
|
+
end
|
499
|
+
|
500
|
+
(0...effective_token_size).each do |ofs_x|
|
501
|
+
(0...effective_token_size).each do |ofs_y|
|
502
|
+
relative_x = pos_x + ofs_x
|
503
|
+
relative_y = pos_y + ofs_y
|
504
|
+
|
505
|
+
return false if relative_x >= @size[0]
|
506
|
+
return false if relative_y >= @size[1]
|
507
|
+
|
508
|
+
return false if @base_map[relative_x][relative_y] == '#'
|
509
|
+
return false if object_at(relative_x, relative_y) && !object_at(relative_x, relative_y).passable?
|
510
|
+
|
511
|
+
next unless battle && @tokens[relative_x][relative_y]
|
512
|
+
|
513
|
+
location_entity = @tokens[relative_x][relative_y][:entity]
|
514
|
+
|
515
|
+
next if @tokens[relative_x][relative_y][:entity] == entity
|
516
|
+
next unless battle.opposing?(location_entity, entity)
|
517
|
+
next if location_entity.dead? || location_entity.unconscious?
|
518
|
+
if entity.class_feature?('halfling_nimbleness') && (location_entity.size_identifier - entity.size_identifier) >= 1
|
519
|
+
next
|
520
|
+
end
|
521
|
+
if battle.opposing?(location_entity,
|
522
|
+
entity) && (location_entity.size_identifier - entity.size_identifier).abs < 2
|
523
|
+
return false
|
524
|
+
end
|
525
|
+
end
|
526
|
+
end
|
527
|
+
|
528
|
+
true
|
529
|
+
end
|
530
|
+
|
531
|
+
def squares_in_path(pos1_x, pos1_y, pos2_x, pos2_y, distance: nil, inclusive: true)
|
532
|
+
if [pos1_x, pos1_y] == [pos2_x, pos2_y]
|
533
|
+
return inclusive ? [[pos1_x, pos1_y]] : []
|
534
|
+
end
|
535
|
+
|
536
|
+
arrs = []
|
537
|
+
if pos2_x == pos1_x
|
538
|
+
scanner = pos2_y > pos1_y ? (pos1_y...pos2_y) : (pos2_y...pos1_y)
|
539
|
+
|
540
|
+
scanner.each_with_index do |y, index|
|
541
|
+
break if !distance.nil? && index >= distance
|
542
|
+
next if !inclusive && ((y == pos1_y) || (y == pos2_y))
|
543
|
+
|
544
|
+
arrs << [pos1_x, y]
|
545
|
+
end
|
546
|
+
else
|
547
|
+
m = (pos2_y - pos1_y).to_f / (pos2_x - pos1_x)
|
548
|
+
scanner = pos2_x > pos1_x ? (pos1_x...pos2_x) : (pos2_x...pos1_x)
|
549
|
+
if m.zero?
|
550
|
+
scanner.each_with_index do |x, index|
|
551
|
+
break if !distance.nil? && index >= distance
|
552
|
+
next if !inclusive && ((x == pos1_x) || (x == pos2_x))
|
553
|
+
|
554
|
+
arrs << [x, pos2_y]
|
555
|
+
end
|
556
|
+
else
|
557
|
+
b = pos1_y - m * pos1_x
|
558
|
+
step = m.abs > 1 ? 1 / m.abs : m.abs
|
559
|
+
|
560
|
+
scanner.step(step).each_with_index do |x, index|
|
561
|
+
y = (m * x + b).round
|
562
|
+
|
563
|
+
break if !distance.nil? && index >= distance
|
564
|
+
next if !inclusive && ((x.round == pos1_x && y == pos1_y) || (x.round == pos2_x && y == pos2_y))
|
565
|
+
|
566
|
+
arrs << [x.round, y]
|
567
|
+
end
|
568
|
+
end
|
569
|
+
end
|
570
|
+
|
571
|
+
arrs.uniq
|
572
|
+
end
|
573
|
+
|
574
|
+
# Determines if it is possible to place a token in this location
|
575
|
+
# @param entity [Natural20::Entity]
|
576
|
+
# @param pos_x [Integer]
|
577
|
+
# @param pos_y [Integer]
|
578
|
+
# @param battle [Natural20::Battle]
|
579
|
+
# @return [Boolean]
|
580
|
+
def placeable?(entity, pos_x, pos_y, battle = nil, squeeze = true)
|
581
|
+
return false unless passable?(entity, pos_x, pos_y, battle, squeeze)
|
582
|
+
|
583
|
+
entity_squares_at_pos(entity, pos_x, pos_y, squeeze).each do |pos|
|
584
|
+
p_x, p_y = pos
|
585
|
+
next if @tokens[p_x][p_y] && @tokens[p_x][p_y][:entity] == entity
|
586
|
+
return false if @tokens[p_x][p_y] && !@tokens[p_x][p_y][:entity].dead?
|
587
|
+
return false if object_at(p_x, p_y) && !object_at(p_x, p_y)&.passable?
|
588
|
+
return false if object_at(p_x, p_y) && !object_at(p_x, p_y)&.placeable?
|
589
|
+
end
|
590
|
+
|
591
|
+
true
|
592
|
+
end
|
593
|
+
|
594
|
+
# Determines if terrain is a difficult terrain
|
595
|
+
# @param entity [Natural20::Entity]
|
596
|
+
# @param pos_x [Integer]
|
597
|
+
# @param pos_y [Integer]
|
598
|
+
# @param battle [Natural20::Battle]
|
599
|
+
# @return [Boolean] Returns if difficult terrain or not
|
600
|
+
def difficult_terrain?(entity, pos_x, pos_y, _battle = nil)
|
601
|
+
entity_squares_at_pos(entity, pos_x, pos_y).each do |pos|
|
602
|
+
r_x, r_y = pos
|
603
|
+
next if @tokens[r_x][r_y] && @tokens[r_x][r_y][:entity] == entity
|
604
|
+
return true if @tokens[r_x][r_y] && !@tokens[r_x][r_y][:entity].dead?
|
605
|
+
return true if object_at(r_x, r_y) && object_at(r_x, r_y)&.movement_cost > 1
|
606
|
+
end
|
607
|
+
|
608
|
+
false
|
609
|
+
end
|
610
|
+
|
611
|
+
def jump_required?(entity, pos_x, pos_y)
|
612
|
+
entity_squares_at_pos(entity, pos_x, pos_y).each do |pos|
|
613
|
+
r_x, r_y = pos
|
614
|
+
next if @tokens[r_x][r_y] && @tokens[r_x][r_y][:entity] == entity
|
615
|
+
return true if object_at(r_x, r_y) && object_at(r_x, r_y)&.jump_required?
|
616
|
+
end
|
617
|
+
|
618
|
+
false
|
619
|
+
end
|
620
|
+
|
621
|
+
# check if this interrupts line of sight (not necessarily movement)
|
622
|
+
def opaque?(pos_x, pos_y)
|
623
|
+
case (@base_map[pos_x][pos_y])
|
624
|
+
when '#'
|
625
|
+
true
|
626
|
+
when '.'
|
627
|
+
false
|
628
|
+
else
|
629
|
+
object_at(pos_x, pos_y)&.opaque?
|
630
|
+
end
|
631
|
+
end
|
632
|
+
|
633
|
+
# Computes one of sight between two points
|
634
|
+
# @param pos1_x [Integer]
|
635
|
+
# @param pos1_y [Integer]
|
636
|
+
# @param pos2_x [Integer]
|
637
|
+
# @param pos2_y [Integer]
|
638
|
+
# @param distance [Integer]
|
639
|
+
# @return [Array<Array<Integer,Integer>>] Cover characteristics if there is LOS
|
640
|
+
def line_of_sight?(pos1_x, pos1_y, pos2_x, pos2_y, distance = nil, inclusive = false, entity = false)
|
641
|
+
squares = squares_in_path(pos1_x, pos1_y, pos2_x, pos2_y, inclusive: inclusive)
|
642
|
+
squares.each_with_index.map do |s, index|
|
643
|
+
return nil if distance && index == (distance - 1)
|
644
|
+
return nil if opaque?(*s)
|
645
|
+
return nil if cover_at(*s) == :total
|
646
|
+
|
647
|
+
[cover_at(*s, entity), s]
|
648
|
+
end
|
649
|
+
end
|
650
|
+
|
651
|
+
# @param pos_x [Integer]
|
652
|
+
# @param pos_y [Integer]
|
653
|
+
# @param entity [Boolean] inlcude entities
|
654
|
+
def cover_at(pos_x, pos_y, entity = false)
|
655
|
+
return :half if object_at(pos_x, pos_y)&.half_cover?
|
656
|
+
return :three_quarter if object_at(pos_x, pos_y)&.three_quarter_cover?
|
657
|
+
return :total if object_at(pos_x, pos_y)&.total_cover?
|
658
|
+
|
659
|
+
return entity_at(pos_x, pos_y).size_identifier if entity && entity_at(pos_x, pos_y)
|
660
|
+
|
661
|
+
:none
|
662
|
+
end
|
663
|
+
|
664
|
+
# highlights objects of interest if enabled on object
|
665
|
+
# @param source [Natural20::Entity] entity that is observing
|
666
|
+
# @param perception_check [Integer] Perception value
|
667
|
+
# @returns [Hash] objects of interest with notes
|
668
|
+
def highlight(source, perception_check)
|
669
|
+
(@entities.keys + interactable_objects.keys).map do |entity|
|
670
|
+
next if source == entity
|
671
|
+
next unless can_see?(source, entity)
|
672
|
+
|
673
|
+
perception_key = "#{source.entity_uid}_#{entity.entity_uid}"
|
674
|
+
perception_check = @session.load_state(:perception).fetch(perception_key, perception_check)
|
675
|
+
@session.save_state(:perception, { perception_key => perception_check })
|
676
|
+
highlighted_notes = entity.try(:list_notes, source, perception_check, highlight: true) || []
|
677
|
+
|
678
|
+
next if highlighted_notes.empty?
|
679
|
+
|
680
|
+
[entity, highlighted_notes]
|
681
|
+
end.compact.to_h
|
682
|
+
end
|
683
|
+
|
684
|
+
# Reads perception related notes on an entity
|
685
|
+
# @param entity [Object] The target to percive on
|
686
|
+
# @param source [Natural20::Entity] Entity perceiving
|
687
|
+
# @param perception_check [Integer] Perception roll of source
|
688
|
+
# @return [Array] Array of notes
|
689
|
+
def perception_on(entity, source, perception_check)
|
690
|
+
perception_key = "#{source.entity_uid}_#{entity.entity_uid}"
|
691
|
+
perception_check = @session.load_state(:perception).fetch(perception_key, perception_check)
|
692
|
+
@session.save_state(:perception, { perception_key => perception_check })
|
693
|
+
entity.try(:list_notes, source, perception_check) || []
|
694
|
+
end
|
695
|
+
|
696
|
+
# Reads perception related notes on an area
|
697
|
+
# @param pos_x [Integer]
|
698
|
+
# @param pos_y [Integer]
|
699
|
+
# @param source [Natural20::Entity]
|
700
|
+
# @param perception_check [Integer] Perception roll of source
|
701
|
+
# @return [Array] Array of notes
|
702
|
+
def perception_on_area(pos_x, pos_y, source, perception_check)
|
703
|
+
notes = @area_notes[pos_x][pos_y] || []
|
704
|
+
|
705
|
+
notes.map do |note_object|
|
706
|
+
note_object.notes.each_with_index.map do |note, index|
|
707
|
+
perception_key = "#{source.entity_uid}_#{note_object.note_id}_#{index}"
|
708
|
+
perception_check = @session.load_state(:perception).fetch(perception_key, perception_check)
|
709
|
+
@session.save_state(:perception, { perception_key => perception_check })
|
710
|
+
next if note[:perception_dc] && perception_check <= note[:perception_dc]
|
711
|
+
|
712
|
+
if note[:perception_dc] && note[:perception_dc] != 0
|
713
|
+
t('perception.passed', note: note[:note])
|
714
|
+
else
|
715
|
+
note[:note]
|
716
|
+
end
|
717
|
+
end
|
718
|
+
end.flatten.compact
|
719
|
+
end
|
720
|
+
|
721
|
+
def area_trigger!(entity, position, is_flying)
|
722
|
+
trigger_results = @area_triggers.map do |k, _prop|
|
723
|
+
next if k.dead?
|
724
|
+
|
725
|
+
k.area_trigger_handler(entity, position, is_flying)
|
726
|
+
end.flatten.compact
|
727
|
+
|
728
|
+
trigger_results.uniq
|
729
|
+
end
|
730
|
+
|
731
|
+
# @param thing [Natural20::Entity]
|
732
|
+
# @return [Array<Integer,Integer>]
|
733
|
+
def entity_or_object_pos(thing)
|
734
|
+
thing.is_a?(ItemLibrary::Object) ? @interactable_objects[thing] : @entities[thing]
|
735
|
+
end
|
736
|
+
|
737
|
+
# Places an object onto the map
|
738
|
+
# @param object_info [Hash]
|
739
|
+
# @param pos_x [Integer]
|
740
|
+
# @param pos_y [Integer]
|
741
|
+
# @param object_meta [Hash]
|
742
|
+
# @return [ItemLibrary::Object]
|
743
|
+
def place_object(object_info, pos_x, pos_y, object_meta = {})
|
744
|
+
return if object_info.nil?
|
745
|
+
|
746
|
+
obj = if object_info.is_a?(ItemLibrary::Object)
|
747
|
+
object_info
|
748
|
+
elsif object_info[:item_class]
|
749
|
+
item_klass = object_info[:item_class].constantize
|
750
|
+
|
751
|
+
item_obj = item_klass.new(self, object_info.merge(object_meta))
|
752
|
+
@area_triggers[item_obj] = {} if item_klass.included_modules.include?(ItemLibrary::AreaTrigger)
|
753
|
+
|
754
|
+
item_obj
|
755
|
+
else
|
756
|
+
ItemLibrary::Object.new(self, object_meta.merge(object_info))
|
757
|
+
end
|
758
|
+
|
759
|
+
@interactable_objects[obj] = [pos_x, pos_y]
|
760
|
+
|
761
|
+
if obj.token.is_a?(Array)
|
762
|
+
obj.token.each_with_index do |line, y|
|
763
|
+
line.each_char.map.to_a.each_with_index do |t, x|
|
764
|
+
next if t == '.' # ignore mask
|
765
|
+
|
766
|
+
@objects[pos_x + x][pos_y + y] << obj
|
767
|
+
end
|
768
|
+
end
|
769
|
+
else
|
770
|
+
@objects[pos_x][pos_y] << obj
|
771
|
+
end
|
772
|
+
|
773
|
+
obj
|
774
|
+
end
|
775
|
+
|
776
|
+
# @param entity [Natural20::Battle]
|
777
|
+
# @param pos_x [Integer]
|
778
|
+
# @param pos_y [Integer]
|
779
|
+
# @param group [Symbol]
|
780
|
+
def add(entity, pos_x, pos_y, group: :b)
|
781
|
+
@unaware_npcs << { group: group&.to_sym || :b, entity: entity }
|
782
|
+
@entities[entity] = [pos_x, pos_y]
|
783
|
+
place(pos_x, pos_y, entity, nil)
|
784
|
+
end
|
785
|
+
|
786
|
+
protected
|
787
|
+
|
788
|
+
def compute_lights
|
789
|
+
@light_map = @light_builder.build_map
|
790
|
+
end
|
791
|
+
|
792
|
+
def setup_objects
|
793
|
+
@size[0].times do |pos_x|
|
794
|
+
@size[1].times do |pos_y|
|
795
|
+
tokens = [@base_map_1[pos_x][pos_y], @base_map[pos_x][pos_y]].compact
|
796
|
+
tokens.map do |token|
|
797
|
+
case token
|
798
|
+
when '#'
|
799
|
+
object_info = @session.load_object('stone_wall')
|
800
|
+
obj = ItemLibrary::StoneWall.new(self, object_info)
|
801
|
+
@interactable_objects[obj] = [pos_x, pos_y]
|
802
|
+
place_object(obj, pos_x, pos_y)
|
803
|
+
when '?'
|
804
|
+
nil
|
805
|
+
when '.'
|
806
|
+
place_object(ItemLibrary::Ground.new(self, name: 'ground'), pos_x, pos_y)
|
807
|
+
else
|
808
|
+
object_meta = @legend[token.to_sym]
|
809
|
+
raise "unknown object token #{token}" if object_meta.nil?
|
810
|
+
next nil if object_meta[:type] == 'mask' # this is a mask terrain so ignore
|
811
|
+
|
812
|
+
object_info = @session.load_object(object_meta[:type])
|
813
|
+
place_object(object_info, pos_x, pos_y, object_meta)
|
814
|
+
end
|
815
|
+
end
|
816
|
+
end
|
817
|
+
end
|
818
|
+
end
|
819
|
+
|
820
|
+
def setup_npcs
|
821
|
+
@meta_map&.each_with_index do |meta_row, column_index|
|
822
|
+
meta_row.each_with_index do |token, row_index|
|
823
|
+
token_type = @legend.dig(token.to_sym, :type)
|
824
|
+
|
825
|
+
case token_type
|
826
|
+
when 'npc'
|
827
|
+
npc_meta = @legend[token.to_sym]
|
828
|
+
raise 'npc type requires sub_type as well' unless npc_meta[:sub_type]
|
829
|
+
|
830
|
+
entity = session.npc(npc_meta[:sub_type].to_sym, name: npc_meta[:name], overrides: npc_meta[:overrides],
|
831
|
+
rand_life: true)
|
832
|
+
|
833
|
+
add(entity, column_index, row_index, group: npc_meta[:group])
|
834
|
+
when 'spawn_point'
|
835
|
+
@spawn_points[@legend.dig(token.to_sym, :name)] = {
|
836
|
+
location: [column_index, row_index]
|
837
|
+
}
|
838
|
+
end
|
839
|
+
end
|
840
|
+
end
|
841
|
+
end
|
842
|
+
end
|
843
|
+
end
|