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