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.
Files changed (114) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +14 -0
  3. data/.rspec +3 -0
  4. data/.travis.yml +6 -0
  5. data/CODE_OF_CONDUCT.md +74 -0
  6. data/Gemfile +7 -0
  7. data/LICENSE.txt +21 -0
  8. data/README.md +99 -0
  9. data/Rakefile +6 -0
  10. data/bin/compute_lights +19 -0
  11. data/bin/console +19 -0
  12. data/bin/nat20 +135 -0
  13. data/bin/nat20.cmd +3 -0
  14. data/bin/nat20author +104 -0
  15. data/bin/setup +8 -0
  16. data/char_classes/fighter.yml +45 -0
  17. data/char_classes/rogue.yml +54 -0
  18. data/characters/halfling_rogue.yml +46 -0
  19. data/characters/high_elf_fighter.yml +49 -0
  20. data/fixtures/battle_sim.yml +58 -0
  21. data/fixtures/battle_sim_2.yml +30 -0
  22. data/fixtures/battle_sim_3.yml +26 -0
  23. data/fixtures/battle_sim_4.yml +26 -0
  24. data/fixtures/battle_sim_objects.yml +101 -0
  25. data/fixtures/corridors.yml +24 -0
  26. data/fixtures/elf_rogue.yml +39 -0
  27. data/fixtures/halfling_rogue.yml +41 -0
  28. data/fixtures/high_elf_fighter.yml +49 -0
  29. data/fixtures/human_fighter.yml +48 -0
  30. data/fixtures/path_finding_test.yml +11 -0
  31. data/fixtures/path_finding_test_2.yml +15 -0
  32. data/fixtures/path_finding_test_3.yml +26 -0
  33. data/fixtures/thin_walls.yml +53 -0
  34. data/fixtures/traps.yml +25 -0
  35. data/game.yml +20 -0
  36. data/items/equipment.yml +101 -0
  37. data/items/objects.yml +73 -0
  38. data/items/weapons.yml +297 -0
  39. data/lib/natural_20.rb +68 -0
  40. data/lib/natural_20/actions/action.rb +40 -0
  41. data/lib/natural_20/actions/attack_action.rb +372 -0
  42. data/lib/natural_20/actions/concerns/action_damage.rb +14 -0
  43. data/lib/natural_20/actions/dash_action.rb +46 -0
  44. data/lib/natural_20/actions/disengage_action.rb +53 -0
  45. data/lib/natural_20/actions/dodge_action.rb +45 -0
  46. data/lib/natural_20/actions/escape_grapple_action.rb +97 -0
  47. data/lib/natural_20/actions/first_aid_action.rb +109 -0
  48. data/lib/natural_20/actions/grapple_action.rb +185 -0
  49. data/lib/natural_20/actions/ground_interact_action.rb +74 -0
  50. data/lib/natural_20/actions/help_action.rb +56 -0
  51. data/lib/natural_20/actions/hide_action.rb +53 -0
  52. data/lib/natural_20/actions/interact_action.rb +91 -0
  53. data/lib/natural_20/actions/inventory_action.rb +23 -0
  54. data/lib/natural_20/actions/look_action.rb +63 -0
  55. data/lib/natural_20/actions/move_action.rb +254 -0
  56. data/lib/natural_20/actions/multiattack_action.rb +41 -0
  57. data/lib/natural_20/actions/prone_action.rb +38 -0
  58. data/lib/natural_20/actions/short_rest_action.rb +53 -0
  59. data/lib/natural_20/actions/shove_action.rb +142 -0
  60. data/lib/natural_20/actions/stand_action.rb +47 -0
  61. data/lib/natural_20/actions/use_item_action.rb +57 -0
  62. data/lib/natural_20/ai_controller/path_compute.rb +140 -0
  63. data/lib/natural_20/ai_controller/standard.rb +288 -0
  64. data/lib/natural_20/battle.rb +544 -0
  65. data/lib/natural_20/battle_map.rb +843 -0
  66. data/lib/natural_20/cli/builder/fighter_builder.rb +104 -0
  67. data/lib/natural_20/cli/builder/rogue_builder.rb +62 -0
  68. data/lib/natural_20/cli/character_builder.rb +210 -0
  69. data/lib/natural_20/cli/commandline_ui.rb +612 -0
  70. data/lib/natural_20/cli/inventory_ui.rb +136 -0
  71. data/lib/natural_20/cli/map_renderer.rb +165 -0
  72. data/lib/natural_20/concerns/container.rb +32 -0
  73. data/lib/natural_20/concerns/entity.rb +1213 -0
  74. data/lib/natural_20/concerns/evaluator/entity_state_evaluator.rb +59 -0
  75. data/lib/natural_20/concerns/fighter_actions/second_wind_action.rb +51 -0
  76. data/lib/natural_20/concerns/fighter_class.rb +35 -0
  77. data/lib/natural_20/concerns/health_flavor.rb +27 -0
  78. data/lib/natural_20/concerns/lootable.rb +94 -0
  79. data/lib/natural_20/concerns/movement_helper.rb +195 -0
  80. data/lib/natural_20/concerns/multiattack.rb +54 -0
  81. data/lib/natural_20/concerns/navigation.rb +87 -0
  82. data/lib/natural_20/concerns/notable.rb +37 -0
  83. data/lib/natural_20/concerns/rogue_class.rb +26 -0
  84. data/lib/natural_20/controller.rb +11 -0
  85. data/lib/natural_20/die_roll.rb +331 -0
  86. data/lib/natural_20/event_manager.rb +288 -0
  87. data/lib/natural_20/item_library/base_item.rb +27 -0
  88. data/lib/natural_20/item_library/chest.rb +230 -0
  89. data/lib/natural_20/item_library/door_object.rb +189 -0
  90. data/lib/natural_20/item_library/ground.rb +124 -0
  91. data/lib/natural_20/item_library/healing_potion.rb +51 -0
  92. data/lib/natural_20/item_library/object.rb +153 -0
  93. data/lib/natural_20/item_library/pit_trap.rb +69 -0
  94. data/lib/natural_20/item_library/stone_wall.rb +18 -0
  95. data/lib/natural_20/npc.rb +173 -0
  96. data/lib/natural_20/player_character.rb +414 -0
  97. data/lib/natural_20/session.rb +168 -0
  98. data/lib/natural_20/utils/cover.rb +35 -0
  99. data/lib/natural_20/utils/ray_tracer.rb +90 -0
  100. data/lib/natural_20/utils/static_light_builder.rb +72 -0
  101. data/lib/natural_20/utils/weapons.rb +78 -0
  102. data/lib/natural_20/version.rb +4 -0
  103. data/locales/en.yml +304 -0
  104. data/maps/game_map.yml +168 -0
  105. data/natural_20.gemspec +46 -0
  106. data/npcs/goblin.yml +64 -0
  107. data/npcs/human_guard.yml +48 -0
  108. data/npcs/ogre.yml +61 -0
  109. data/npcs/owlbear.yml +55 -0
  110. data/npcs/wolf.yml +46 -0
  111. data/races/elf.yml +44 -0
  112. data/races/halfling.yml +22 -0
  113. data/races/human.yml +13 -0
  114. 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