natural_20 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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