natural_20 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5be1c9cb751acb1ca14c7fd56510c5cd3546319402847845d42340dcc56ee5c9
4
- data.tar.gz: 6cb35a23e305f0218e21f2ad9e3b750df99436c3f8bdb9a5fa210ba1380c406a
3
+ metadata.gz: e33ce92d3b1d8ebf75e0262bb56ab3e990702897632c2ebdb476e814d8abfe4a
4
+ data.tar.gz: c3aa5ca94f3d587466f4ec93fd44c05a58af2f009cd58cdf50f36b9e9be1bd75
5
5
  SHA512:
6
- metadata.gz: d84d618418d15c13d6845ce947331c0d0adf61e8be9ea8e6766da2aa239c063516c5d3871075aecfcf5099d84af8dcc1206548523ec5560c49573a30fbcef5f5
7
- data.tar.gz: 7c7b48733c61254779b5037cd7ec95303faf4a1c1088f38ece602e4b665b31b6b98136b229387ae0bca0c315e71c35c77673a2824ecbfbd40e5d2116850d5187
6
+ metadata.gz: 568d09d82b1bdfb16696f19082e673ce540f01ff9688ec8293898685497df8c14c4b8f29ccb42ec6b2d7c0e971645074f05a282dd76e347acf0e45b7744fb95f
7
+ data.tar.gz: a2896d2bc69e40345675a7b4243b18b72562266ee39c32017ea420b00b68d996f12e0fbe410fc0fa10383dfdcc205a0be7c7d70f044605460281275f8b1b59dc
data/README.md CHANGED
@@ -9,12 +9,14 @@ Features:
9
9
  - Simulation of doors, traps, treasure chests and cover
10
10
  - Rudimentary AI and pathfinding
11
11
  - Text based UI
12
+ - Support for automatic and manual dice rolling
12
13
  - Easily extensible to incorporate in your own games
13
14
 
14
15
  Supported Races:
15
16
  - Human
16
17
  - Elf
17
18
  - Halfling
19
+ - Dwarf
18
20
  - More to come
19
21
 
20
22
  Supported Classes;
@@ -69,6 +71,13 @@ game.yml
69
71
 
70
72
  These are all text readable for you to customize to your liking.
71
73
 
74
+ ## Screenshots
75
+
76
+ ![Screenshot from 2021-02-06 13-14-48](https://user-images.githubusercontent.com/949459/107109725-91212000-687d-11eb-8f59-3ecd2d5efa9e.png)
77
+ ![Screenshot from 2021-02-06 13-15-13](https://user-images.githubusercontent.com/949459/107109730-941c1080-687d-11eb-94ad-565d4bde5cfd.png)
78
+ ![Screenshot from 2021-02-06 13-15-37](https://user-images.githubusercontent.com/949459/107109733-95e5d400-687d-11eb-8928-53caad6f9b28.png)
79
+
80
+
72
81
  ## Creating your own adventures
73
82
 
74
83
  You can generate a skeleton adventure using:
@@ -79,6 +88,19 @@ nat20author
79
88
 
80
89
  A prompt based system will launch for you to create your own game.
81
90
 
91
+ The end result will be a folder containing a bunch of YAML files describing your game
92
+
93
+ ## Map making guide
94
+
95
+ A map is a YAML file that consists of the following:
96
+
97
+ - Base Layer - contains terrain information like ground, grass, walls, doors, traps and various obstacles
98
+ - Meta Layer - contains information about dynamic objects like player tokens, NPCs
99
+ - Light Layer - contains information about static lights in the map
100
+ - Legend - A mapping from the layers to details about the terrain and tokens
101
+ - Map Triggers - A map level trigger
102
+
103
+
82
104
  ## Development
83
105
 
84
106
  After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
@@ -0,0 +1,27 @@
1
+ name: Natural20::Battle Sim
2
+ description: Map for Natural20::Battle Sim
3
+ map:
4
+ base:
5
+ - ......
6
+ - ......
7
+ - .MMMM.
8
+ - .MMMM.
9
+ - .MMMM.
10
+ - ......
11
+ meta:
12
+ - ......
13
+ - ......
14
+ - ......
15
+ - ..A...
16
+ - ......
17
+ - .B....
18
+ legend:
19
+ A:
20
+ name: spawn_point_1
21
+ type: spawn_point
22
+ B:
23
+ name: spawn_point_2
24
+ type: spawn_point
25
+ M:
26
+ name: Tall Grass
27
+ type: briar
data/items/objects.yml CHANGED
@@ -61,6 +61,29 @@ water:
61
61
  passable: true
62
62
  token:
63
63
  - ^
64
+ briar:
65
+ color: green
66
+ passable: true
67
+ placeable: true
68
+ movement_cost: 2
69
+ cover: half
70
+ default_ac: 10
71
+ hp_die: 1d4
72
+ max_hp: 4
73
+ allow_hide: true
74
+ token:
75
+ - ▓
76
+ tree:
77
+ color: green
78
+ passable: true
79
+ placeable: true
80
+ cover: half
81
+ default_ac: 10
82
+ hp_die: 1d8
83
+ max_hp: 8
84
+ allow_hide: true
85
+ token:
86
+ - '*'
64
87
  wooden_door:
65
88
  color: magenta
66
89
  default_ac: 15
data/items/weapons.yml CHANGED
@@ -1,4 +1,22 @@
1
1
  ---
2
+ battleaxe:
3
+ cost: 10
4
+ damage: 1d8
5
+ damage_2: 1d10
6
+ damage_type: slashing
7
+ meta:
8
+ noise_source: 5
9
+ noise_target: 5
10
+ modifiers: null
11
+ name: Battleaxe
12
+ proficiency_type:
13
+ - martial
14
+ properties:
15
+ - versatile
16
+ range: 5
17
+ subtype: weapon
18
+ type: melee_attack
19
+ weight: 4
2
20
  dagger:
3
21
  cost: 2
4
22
  damage: 1d4
@@ -123,6 +141,45 @@ light_crossbow:
123
141
  subtype: weapon
124
142
  type: ranged_attack
125
143
  weight: 2
144
+ light_hammer:
145
+ cost: 2
146
+ damage: 1d4
147
+ damage_type: bludgeoning
148
+ meta:
149
+ noise_source: 5
150
+ noise_target: 5
151
+ modifiers: null
152
+ name: Light Hammer
153
+ proficiency_type:
154
+ - simple
155
+ properties:
156
+ - light
157
+ - thrown
158
+ range: 5
159
+ subtype: weapon
160
+ thrown:
161
+ range: 20
162
+ range_max: 60
163
+ type: melee_attack
164
+ weight: 2
165
+ warhammer:
166
+ cost: 2
167
+ damage: 1d8
168
+ damage_2: 1d10
169
+ damage_type: bludgeoning
170
+ meta:
171
+ noise_source: 5
172
+ noise_target: 5
173
+ modifiers: null
174
+ name: Warhammer
175
+ proficiency_type:
176
+ - martial
177
+ properties:
178
+ - versatile
179
+ range: 5
180
+ subtype: weapon
181
+ type: melee_attack
182
+ weight: 2
126
183
  longbow:
127
184
  ammo: arrows
128
185
  cost: 50
@@ -11,7 +11,9 @@ class AttackAction < Natural20::Action
11
11
  # @param battle [Natural20::Battle]
12
12
  # @return [Boolean]
13
13
  def self.can?(entity, battle, options = {})
14
- battle.nil? || entity.total_actions(battle).positive? || (options[:opportunity_attack] && entity.total_reactions(battle).positive?) || entity.multiattack?(
14
+ return entity.total_reactions(battle).positive? if battle && options[:opportunity_attack]
15
+
16
+ battle.nil? || entity.total_actions(battle).positive? || entity.multiattack?(
15
17
  battle, options[:npc_action]
16
18
  )
17
19
  end
@@ -109,11 +111,11 @@ class AttackAction < Natural20::Action
109
111
  end
110
112
 
111
113
  if as_reaction
112
- battle.entity_state_for(item[:source])[:reaction] -= 1
114
+ battle.consume(item[:source], :reaction)
113
115
  elsif item[:second_hand]
114
- battle.entity_state_for(item[:source])[:bonus_action] -= 1
116
+ battle.consume(item[:source], :bonus_action)
115
117
  else
116
- battle.entity_state_for(item[:source])[:action] -= 1
118
+ battle.consume(item[:source], :action)
117
119
  end
118
120
 
119
121
  item[:source].break_stealth!(battle)
@@ -339,7 +341,7 @@ class AttackAction < Natural20::Action
339
341
  _advantage, disadvantage = adv_info
340
342
  disadvantage << :protection
341
343
  @advantage_mod = -1
342
- battle.entity_state_for(entity)[:reaction] -= 1
344
+ battle.consume(entity, :reaction)
343
345
  end
344
346
  end
345
347
 
@@ -357,9 +359,9 @@ class TwoWeaponAttackAction < AttackAction
357
359
  # @param entity [Natural20::Entity]
358
360
  # @param battle [Natural20::Battle]
359
361
  def self.can?(entity, battle, options = {})
360
- battle.nil? || (entity.total_bonus_actions(battle).positive? && battle.two_weapon_attack?(entity) && options[:weapon] != battle.first_hand_weapon(entity) || entity.equipped_weapons.select do |a|
361
- a == battle.first_hand_weapon(entity)
362
- end.size >= 2)
362
+ battle.nil? || (entity.total_bonus_actions(battle).positive? && battle.two_weapon_attack?(entity) && (options[:weapon] != battle.first_hand_weapon(entity) || entity.equipped_weapons.select do |a|
363
+ a.to_s == battle.first_hand_weapon(entity)
364
+ end.size >= 2))
363
365
  end
364
366
 
365
367
  def second_hand
@@ -9,6 +9,7 @@ class FirstAidAction < Natural20::Action
9
9
  # @param battle [Natural20::Battle]
10
10
  def self.unconscious_targets(entity, battle)
11
11
  return [] unless battle
12
+ return [] unless battle.map
12
13
 
13
14
  adjacent_squares = entity.melee_squares(battle.map, adjacent_only: true)
14
15
  entities = []
@@ -11,7 +11,12 @@ class GroundInteractAction < Natural20::Action
11
11
  action.build_map
12
12
  end
13
13
 
14
+ # @param entity [Natural20::Entity]
15
+ # @param battle [Natural20::Battle]
16
+ # @return [Integer]
14
17
  def self.items_on_the_ground_count(entity, battle)
18
+ return 0 unless battle.map
19
+
15
20
  battle.map.items_on_the_ground(entity).inject(0) { |total, item| total + item[1].size }
16
21
  end
17
22
 
@@ -21,6 +21,7 @@ class HideAction < Natural20::Action
21
21
  stealth_roll = @source.stealth_check!(opts[:battle])
22
22
  @result = [{
23
23
  source: @source,
24
+ bonus_action: as_bonus_action,
24
25
  type: :hide,
25
26
  roll: stealth_roll,
26
27
  battle: opts[:battle]
@@ -37,10 +38,10 @@ class HideAction < Natural20::Action
37
38
  item[:source].hiding!(battle, item[:roll].result)
38
39
  end
39
40
 
40
- if as_bonus_action
41
- battle.entity_state_for(item[:source])[:bonus_action] -= 1
41
+ if item[:bonus_action]
42
+ battle.consume(item[:source], :bonus_action)
42
43
  else
43
- battle.entity_state_for(item[:source])[:action] -= 1
44
+ battle.consume(item[:source], :action)
44
45
  end
45
46
  end
46
47
  end
@@ -127,42 +127,19 @@ class MoveAction < Natural20::Action
127
127
  # @param move_list [Array<Array<Integer,Integer>>]
128
128
  # @param battle [Natural20::Battle]
129
129
  def check_opportunity_attacks(entity, move_list, battle, grappled: false)
130
- if battle && !@source.disengage?(battle)
131
- opportunity_attacks = opportunity_attack_list(entity, move_list, battle, battle.map)
132
- opportunity_attacks.each do |enemy_opporunity|
133
- next unless enemy_opporunity[:source].has_reaction?(battle)
134
- next if @source.grappling_targets.include?(enemy_opporunity[:source])
130
+ if battle
135
131
 
132
+ retrieve_opportunity_attacks(entity, move_list, battle).each do |enemy_opporunity|
136
133
  original_location = move_list[0...enemy_opporunity[:path]]
137
134
  attack_location = original_location.last
138
135
  battle.trigger_opportunity_attack(enemy_opporunity[:source], entity, *attack_location)
139
136
 
140
- if !grappled && !entity.conscious?
141
- move_list = original_location
142
- break
143
- end
137
+ return original_location if !grappled && !entity.conscious?
144
138
  end
145
139
  end
146
140
  move_list
147
141
  end
148
142
 
149
- def opportunity_attack_list(entity, current_moves, battle, map)
150
- # get opposing forces
151
- opponents = battle.opponents_of?(entity)
152
- entered_melee_range = Set.new
153
- left_melee_range = []
154
- current_moves.each_with_index do |path, index|
155
- opponents.each do |enemy|
156
- entered_melee_range.add(enemy) if enemy.entered_melee?(map, entity, *path)
157
- if !left_melee_range.include?(enemy) && entered_melee_range.include?(enemy) && !enemy.entered_melee?(map,
158
- entity, *path)
159
- left_melee_range << { source: enemy, path: index }
160
- end
161
- end
162
- end
163
- left_melee_range
164
- end
165
-
166
143
  def apply!(battle)
167
144
  @result.each do |item|
168
145
  case (item[:type])
@@ -127,7 +127,7 @@ module AiController
127
127
  # generate available targets
128
128
  valid_actions = []
129
129
 
130
- if enemy_positions.empty? && LookAction.can?(entity, battle)
130
+ if enemy_positions.empty? && investigate_location.empty? && LookAction.can?(entity, battle)
131
131
  action = LookAction.new(battle.session, entity, :look)
132
132
  return action
133
133
  end
@@ -163,6 +163,19 @@ module AiController
163
163
  valid_actions += generate_moves_for_positions(battle, entity, investigate_location)
164
164
  end
165
165
 
166
+ if HideBonusAction.can?(entity, battle) # bonus action hide if able
167
+ hide_action = HideBonusAction.new(battle.session, entity, :hide_bonus)
168
+ hide_action.as_bonus_action = true
169
+ valid_actions << hide_action
170
+ end
171
+
172
+ if valid_actions.first&.action_type == :move && DisengageBonusAction.can?(entity,
173
+ battle) && !retrieve_opportunity_attacks(
174
+ entity, valid_actions.first.move_path, battle
175
+ ).empty?
176
+ return DisengageBonusAction.new(battle.session, entity, :disengage_bonus)
177
+ end
178
+
166
179
  valid_actions << DodgeAction.new(battle.session, entity, :dodge) if entity.action?(battle)
167
180
 
168
181
  return valid_actions.first unless valid_actions.empty?
@@ -195,7 +208,7 @@ module AiController
195
208
  melee_weight = 2.0
196
209
  end
197
210
 
198
- defense_weight = 2.0 if (entity.hp / entity.max_hp) < 0.25
211
+ # defense_weight = 2.0 if (entity.hp / entity.max_hp) < 0.25
199
212
 
200
213
  [square, melee_weight * melee + range_weight * ranged + defense_weight * defense + mobility * mobolity_weight]
201
214
  end
@@ -64,7 +64,7 @@ module Natural20
64
64
 
65
65
  # Adds an entity to the battle
66
66
  # @param entity [Natural20::Entity] The entity to add to the battle
67
- # @param group [Symbol] A symbol denoting which group this entity belongs to
67
+ # @param group [Symbol] A symbol denoting which opposing group this entity belongs to
68
68
  # @param controller [AiController::Standard] Ai controller to use
69
69
  # @param position [Array, Symbol] Starting location in the map can be a position or a spawn point
70
70
  # @param token [String, Symbol] The token to use
@@ -279,6 +279,7 @@ module Natural20
279
279
 
280
280
  cover_value = @map.cover_calculation(@map, entity1, entity2, entity_1_pos: entity_1_pos,
281
281
  naturally_stealthy: entity2.class_feature?('naturally_stealthy'))
282
+
282
283
  if cover_value.positive?
283
284
  entity_2_state = entity_state_for(entity2)
284
285
  return false if entity_2_state[:stealth] > [active_perception, entity1.passive_perception].max
@@ -382,7 +383,7 @@ module Natural20
382
383
 
383
384
  def while_active(max_rounds = nil, &block)
384
385
  loop do
385
- Natural20::EventManager.received_event(source: self, event: :start_of_round)
386
+ Natural20::EventManager.received_event(source: self, event: :start_of_round, in_battle: ongoing?)
386
387
 
387
388
  current_turn.death_saving_throw!(self) if current_turn.unconscious? && !current_turn.stable?
388
389
 
@@ -522,6 +523,15 @@ module Natural20
522
523
  @map.activate_map_triggers(event, source, opt.merge(ui_controller: controller_for(source)))
523
524
  end
524
525
 
526
+ # Consumes an action resource
527
+ # @param entity [Natural20::Entity]
528
+ # @param resouce [Symbol]
529
+ def consume(entity, resource, qty = 1)
530
+ raise 'invalid resource' unless %i[action reaction bonus_action movement].include?(resource.to_sym)
531
+
532
+ entity_state_for(entity)[resource.to_sym] = [0, entity_state_for(entity)[resource.to_sym] - qty].max
533
+ end
534
+
525
535
  protected
526
536
 
527
537
  def t(key, options = {})
@@ -135,7 +135,7 @@ module Natural20
135
135
  # Get object at map location
136
136
  # @param pos_x [Integer]
137
137
  # @param pos_y [Integer]
138
- # @return [ItemLibrary::Object]
138
+ # @return [Array<ItemLibrary::Object>]
139
139
  def objects_at(pos_x, pos_y)
140
140
  @objects[pos_x][pos_y]
141
141
  end
@@ -228,13 +228,13 @@ module Natural20
228
228
  # @param entity1 [Natural20::Entity]
229
229
  # @param entity2 [Natural20::Entity]
230
230
  # @result [Integer]
231
- def distance(entity1, entity2, entity_1_pos: nil)
231
+ def distance(entity1, entity2, entity_1_pos: nil, entity_2_pos: nil)
232
232
  raise 'entity 1 param cannot be nil' if entity1.nil?
233
233
  raise 'entity 2 param cannot be nil' if entity2.nil?
234
234
 
235
235
  # entity 1 squares
236
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)
237
+ entity_2_sq = entity_2_pos ? entity_squares_at_pos(entity2, *entity_2_pos) : entity_squares(entity2)
238
238
 
239
239
  entity_1_sq.map do |ent1_pos|
240
240
  entity_2_sq.map do |ent2_pos|
@@ -245,6 +245,20 @@ module Natural20
245
245
  end.flatten.min
246
246
  end
247
247
 
248
+ # Returns the line distance between an entity and a location
249
+ # @param entity [Natural20::Entity]
250
+ # @param pos2_x [Integer]
251
+ # @param pos2_y [Integer]
252
+ # @result [Integer]
253
+ def line_distance(entity1, pos2_x, pos2_y, entity_1_pos: nil)
254
+ entity_1_sq = entity_1_pos ? entity_squares_at_pos(entity1, *entity_1_pos) : entity_squares(entity1)
255
+
256
+ entity_1_sq.map do |ent1_pos|
257
+ pos1_x, pos1_y = ent1_pos
258
+ Math.sqrt((pos1_x - pos2_x)**2 + (pos1_y - pos2_y)**2).floor
259
+ end.flatten.min
260
+ end
261
+
248
262
  # Get all the location of the squares occupied by said entity (e.g. for large, huge creatures)
249
263
  # @param entity [Natural20::Entity]
250
264
  # @return [Array]
@@ -21,14 +21,14 @@ module Natural20
21
21
  ability: {},
22
22
  skills: [],
23
23
  level: 1,
24
- token: ['X']
24
+ token: ['X'],
25
+ tools: []
25
26
  }
26
27
  loop do
27
28
  ability_method = :random
28
29
 
29
30
  @values[:name] = prompt.ask(t('builder.enter_name'), default: @values[:name]) do |q|
30
31
  q.required true
31
- q.validate(/\A\w+\Z/)
32
32
  q.modify :capitalize
33
33
  end
34
34
 
@@ -88,6 +88,15 @@ module Natural20
88
88
  @values[:classes][k.to_sym] = 1
89
89
  @class_properties = session.load_class(k)
90
90
 
91
+ if race_detail[:tool_proficiencies_choice]
92
+ num_tools_choices = race_detail[:tool_proficiencies_choice]
93
+ @values[:tools] = prompt.multi_select(t('builder.select_tools_proficiency'), min: num_tools_choices, max: num_tools_choices) do |q|
94
+ race_detail[:tool_proficiencies].each do |prof|
95
+ q.choice t("builder.tools.#{prof}"), prof
96
+ end
97
+ end
98
+ end
99
+
91
100
  ability_method = prompt.select(t('builder.ability_score_method')) do |q|
92
101
  q.choice t('builder.ability_score.random'), :random
93
102
  q.choice t('builder.ability_score.fixed'), :fixed
@@ -468,7 +468,7 @@ class CommandlineUI < Natural20::Controller
468
468
  def describe_map(map, line_of_sight: [])
469
469
  line_of_sight = [line_of_sight] unless line_of_sight.is_a?(Array)
470
470
  pov = line_of_sight.map(&:name).join(',')
471
- puts "Battle Map (#{map.size[0]}x#{map.size[1]}) #{map.feet_per_grid}ft per square, pov #{pov}:"
471
+ puts t('map_description', width: map.size[0], length: map.size[1], feet_per_grid: map.feet_per_grid, pov: pov)
472
472
  end
473
473
 
474
474
  # Return moves by a player using the commandline UI
@@ -485,10 +485,10 @@ class CommandlineUI < Natural20::Controller
485
485
  puts t(:character_status_line, hp: entity.hp, max_hp: entity.max_hp, total_actions: entity.total_actions(battle), bonus_action: entity.total_bonus_actions(battle),
486
486
  available_movement: entity.available_movement(battle), statuses: entity.statuses.to_a.join(','))
487
487
 
488
- action = prompt.select("#{entity.name} (#{entity.token&.first}) will", per_page: TTY_PROMPT_PER_PAGE,
489
- filter: true) do |menu|
490
- entity.available_actions(@session, battle).each do |action|
491
- menu.choice action.label, action
488
+ action = prompt.select(t('character_action_prompt', name: entity.name, token: entity.token&.first), per_page: TTY_PROMPT_PER_PAGE,
489
+ filter: true) do |menu|
490
+ entity.available_actions(@session, battle).each do |a|
491
+ menu.choice a.label, a
492
492
  end
493
493
  # menu.choice 'Console (Developer Mode)', :console
494
494
  menu.choice 'End'.colorize(:red), :end
@@ -523,6 +523,7 @@ class CommandlineUI < Natural20::Controller
523
523
  cycles += 1
524
524
  session.save_game(battle)
525
525
  action = battle.move_for(entity)
526
+
526
527
  if action.nil?
527
528
 
528
529
  unless battle.current_party.include?(entity)
@@ -15,11 +15,23 @@ module Natural20
15
15
  # @param select_pos [Array] coordinate position to render selection cursor
16
16
  # @param update_on_drop [Boolean] If true, only render line of sight if movement has been confirmed
17
17
  # @return [String]
18
- def render(entity: nil, line_of_sight: nil, path: [], acrobatics_checks: [], athletics_checks: [], select_pos: nil, update_on_drop: true, path_char: nil, highlight: {})
18
+ def render(entity: nil, line_of_sight: nil, path: [], acrobatics_checks: [], athletics_checks: [], select_pos: nil, update_on_drop: true, path_char: nil, highlight: {}, viewport_size: nil, top_position: [
19
+ 0, 0
20
+ ])
19
21
  highlight_positions = highlight.keys.map { |entity| @map.entity_squares(entity) }.flatten(1)
20
22
 
21
- base_map.transpose.each_with_index.map do |row, row_index|
22
- row.each_with_index.map do |c, col_index|
23
+ viewport_size ||= map.size
24
+
25
+ top_x, top_y = top_position
26
+ top_x = map.size[0] - top_x < viewport_size[0] ? map.size[0] - viewport_size[0] : top_x if viewport_size
27
+ top_y = map.size[0] - top_y < viewport_size[1] ? map.size[1] - viewport_size[1] : top_y if viewport_size
28
+
29
+ right_x = top_x + viewport_size[0] >= map.size[1] ? map.size[1] - 1 : top_x + viewport_size[0]
30
+ right_y = top_y + viewport_size[1] >= map.size[0] ? map.size[0] - 1 : top_y + viewport_size[1]
31
+
32
+ (top_x..right_x).map do |row_index|
33
+ (top_y..right_y).map do |col_index|
34
+ c = map.base_map[col_index][row_index]
23
35
  display = render_position(c, col_index, row_index, path: path, override_path_char: path_char, entity: entity, line_of_sight: line_of_sight,
24
36
  update_on_drop: update_on_drop, acrobatics_checks: acrobatics_checks,
25
37
  athletics_checks: athletics_checks)
@@ -205,11 +205,7 @@ module Natural20
205
205
  ofs_x /= map.feet_per_grid
206
206
  ofs_y /= map.feet_per_grid
207
207
 
208
- if map.placeable?(self, x + ofs_x, y + ofs_y)
209
- [x + ofs_x, y + ofs_y]
210
- else
211
- nil
212
- end
208
+ [x + ofs_x, y + ofs_y] if map.placeable?(self, x + ofs_x, y + ofs_y)
213
209
  end
214
210
 
215
211
  # @param map [Natural20::BattleMap]
@@ -591,6 +587,8 @@ module Natural20
591
587
  SKILL_AND_ABILITY_MAP.each do |ability, skills|
592
588
  skills.each do |skill|
593
589
  define_method("#{skill}_mod") do
590
+ return @properties[:skills][skill.to_sym] if npc? && @properties[:skills] && @properties[:skills][skill.to_sym]
591
+
594
592
  ability_mod = case ability.to_sym
595
593
  when :dex
596
594
  dex_mod
@@ -100,7 +100,7 @@ module Natural20::MovementHelper
100
100
  original_budget = movement_budget
101
101
 
102
102
  current_moves.each_with_index do |m, index|
103
- raise "invalid move coordinate" unless m.size == 2 # assert move correctness
103
+ raise 'invalid move coordinate' unless m.size == 2 # assert move correctness
104
104
 
105
105
  unless index.positive?
106
106
  actual_moves << m
@@ -192,4 +192,36 @@ module Natural20::MovementHelper
192
192
  movement_budget, impediment)
193
193
  end
194
194
 
195
+ # Checks if a move provokes opportunity attacks
196
+ # @param entity [Natural20::Entity]
197
+ # @param move_list [Array<Array<Integer,Integer>>]
198
+ # @param battle [Natural20::Battle]
199
+ # @return [Array<Hash>]
200
+ def retrieve_opportunity_attacks(entity, move_list, battle)
201
+ return [] if entity.disengage?(battle)
202
+
203
+ opportunity_attacks = opportunity_attack_list(entity, move_list, battle, battle.map)
204
+ opportunity_attacks.select do |enemy_opporunity|
205
+ enemy_opporunity[:source].has_reaction?(battle) && !entity.grappling_targets.include?(enemy_opporunity[:source])
206
+ end
207
+ end
208
+
209
+ protected
210
+
211
+ def opportunity_attack_list(entity, current_moves, battle, map)
212
+ # get opposing forces
213
+ opponents = battle.opponents_of?(entity)
214
+ entered_melee_range = Set.new
215
+ left_melee_range = []
216
+ current_moves.each_with_index do |path, index|
217
+ opponents.each do |enemy|
218
+ entered_melee_range.add(enemy) if enemy.entered_melee?(map, entity, *path)
219
+ if !left_melee_range.include?(enemy) && entered_melee_range.include?(enemy) && !enemy.entered_melee?(map,
220
+ entity, *path)
221
+ left_melee_range << { source: enemy, path: index }
222
+ end
223
+ end
224
+ end
225
+ left_melee_range
226
+ end
195
227
  end
@@ -59,7 +59,7 @@ module Natural20::Navigation
59
59
  support = 0.0
60
60
 
61
61
  if melee_attack_squares.key?(d)
62
- melee_offence += 0.1
62
+ melee_offence += 0.2
63
63
  defense -= 0.05 * melee_attack_squares[d]
64
64
  if attack_options
65
65
  opponents.each do |opp|
@@ -81,6 +81,7 @@ module Natural20::Navigation
81
81
  ranged_offence -= 0.5
82
82
  end
83
83
 
84
+ mobility -= 0.001 * map.line_distance(entity, *d)
84
85
  [d, [melee_offence, ranged_offence, defense, mobility, support]]
85
86
  end.to_h
86
87
  end
@@ -73,6 +73,17 @@ module ItemLibrary
73
73
  @properties[:cover] == 'half'
74
74
  end
75
75
 
76
+ def cover_ac
77
+ case @properties[:cover].to_sym
78
+ when :half
79
+ 2
80
+ when :three_quarter
81
+ 5
82
+ else
83
+ 0
84
+ end
85
+ end
86
+
76
87
  def three_quarter_cover?
77
88
  @properties[:cover] == 'three_quarter'
78
89
  end
@@ -81,6 +92,10 @@ module ItemLibrary
81
92
  @properties[:cover] == 'total'
82
93
  end
83
94
 
95
+ def can_hide?
96
+ @properties.fetch(:allow_hide, false)
97
+ end
98
+
84
99
  def interactable?
85
100
  false
86
101
  end
@@ -31,7 +31,7 @@ module Natural20
31
31
  @statuses = Set.new
32
32
  @resistances = []
33
33
  entity_uid = SecureRandom.uuid
34
- setup_attributes
34
+
35
35
  @max_hit_die = {}
36
36
  @current_hit_die = {}
37
37
 
@@ -48,6 +48,8 @@ module Natural20
48
48
 
49
49
  [klass.to_sym, character_class_properties]
50
50
  end.to_h
51
+
52
+ setup_attributes
51
53
  end
52
54
 
53
55
  def name
@@ -55,7 +57,11 @@ module Natural20
55
57
  end
56
58
 
57
59
  def max_hp
58
- @properties[:max_hp]
60
+ if class_feature?('dwarven_toughness')
61
+ @properties[:max_hp] + level
62
+ else
63
+ @properties[:max_hp]
64
+ end
59
65
  end
60
66
 
61
67
  def armor_class
@@ -243,8 +249,12 @@ module Natural20
243
249
  def available_actions(session, battle, opportunity_attack: false)
244
250
  return [] if unconscious?
245
251
 
246
- if opportunity_attack && AttackAction.can?(self, battle, opportunity_attack: true)
247
- return player_character_attack_actions(battle, opportunity_attack: true)
252
+ if opportunity_attack
253
+ if AttackAction.can?(self, battle, opportunity_attack: true)
254
+ return player_character_attack_actions(battle, opportunity_attack: true)
255
+ else
256
+ return []
257
+ end
248
258
  end
249
259
 
250
260
  ACTION_LIST.map do |type|
@@ -324,10 +334,10 @@ module Natural20
324
334
  next unless weapon_detail[:type] == 'melee_attack'
325
335
 
326
336
  next unless weapon_detail[:properties] && weapon_detail[:properties].include?('light') && TwoWeaponAttackAction.can?(
327
- self, battle, weapon: weapon_detail[:name]
337
+ self, battle, weapon: item
328
338
  )
329
339
 
330
- action = TwoWeaponAttackAction.new(session, self, :attack)
340
+ action = TwoWeaponAttackAction.new(session, self, :attack, weapon: item)
331
341
  action.using = item
332
342
  return action
333
343
  end
@@ -384,7 +394,7 @@ module Natural20
384
394
 
385
395
  def setup_attributes
386
396
  super
387
- @hp = @properties[:max_hp]
397
+ @hp = max_hp
388
398
  end
389
399
 
390
400
  def equipped_ac
@@ -17,6 +17,13 @@ module Natural20::Cover
17
17
  next 0 unless cover_characteristics
18
18
 
19
19
  max_ac = 0
20
+
21
+ # check if any objects in the area provide cover
22
+ objs = map.objects_at(*target_pos)
23
+ objs.each do |object|
24
+ max_ac = [max_ac, object.cover_ac].max if object.can_hide?
25
+ end
26
+
20
27
  cover_characteristics.each do |cover|
21
28
  cover_type, pos = cover
22
29
 
@@ -1,4 +1,4 @@
1
1
  # typed: strict
2
2
  module Natural20
3
- VERSION = "0.1.0"
3
+ VERSION = "0.1.1"
4
4
  end
data/locales/en.yml CHANGED
@@ -42,6 +42,8 @@ en:
42
42
  back: Back
43
43
  battle_end: Battle ended in %{num} rounds
44
44
  battle_simulator: Battle Simulator
45
+ map_description: 'Battle Map (%{width}x%{length}) %{feet_per_grid}ft per square, pov %{pov}:'
46
+ character_action_prompt: '%{name} (%{token}) will'
45
47
  builder:
46
48
  ability_score:
47
49
  fixed: Fixed ability score 15, 14, 13, 12, 10, 8
@@ -70,7 +72,9 @@ en:
70
72
  races:
71
73
  dark_elf: Dark Elf
72
74
  high_elf: High Elf
75
+ hill_dwarf: Hill Dwarf
73
76
  lightfoot: Lightfoot
77
+ mountain_dwarf: Mountain Dwarf
74
78
  stout: Stout
75
79
  wood_elf: Wood Elf
76
80
  review: Confirm this character?
@@ -102,6 +106,10 @@ en:
102
106
  str: Select Strength ability score
103
107
  token: Select a token to represent this character on the board
104
108
  token_color: Select a color for your token
109
+ tools:
110
+ brewers_supplies: Brewer's Supplies
111
+ masons_tools: Mason's Tools
112
+ smiths_tools: Smith's Tools
105
113
  wis: Select Wisdom ability score
106
114
  character_builder: Character Builder ...
107
115
  character_sheet:
@@ -118,7 +126,7 @@ en:
118
126
  skills: Skills
119
127
  speed: 'Speed: %{speed}ft.'
120
128
  subrace: 'Subrace: %{race}'
121
- character_status_line: '%{hp}/%{max_hp} actions: %{total_actions} bonus action: %{bonus_action}, movement: %{available_movement} ft. statuses: %{statuses}'
129
+ character_status_line: 'HP: %{hp}/%{max_hp} actions: %{total_actions} bonus action: %{bonus_action}, movement: %{available_movement} ft. statuses: %{statuses}'
122
130
  dead_goblin_items: Dead Goblin's items
123
131
  dice_roll:
124
132
  ability_score: 'Rolling for ability score #%{roll_num}'
@@ -224,8 +232,8 @@ en:
224
232
  thieves_cant: Thieves Cant
225
233
  undercommon: Undercommon
226
234
  manual_target: Target object using the map
227
- multiple_target_prompt: Multiple targets at location(s) please select specific targets
228
235
  missing_game: missing game.yml in the current folder
236
+ multiple_target_prompt: Multiple targets at location(s) please select specific targets
229
237
  'no': 'No'
230
238
  object:
231
239
  Arrows: Arrows
@@ -263,6 +271,7 @@ en:
263
271
  two_handaxes: Two handaxes
264
272
  two_martial_weapons: (2) Martial Weapons
265
273
  weapons:
274
+ battleaxe: Battleaxe
266
275
  chain_mail: Chain Mail
267
276
  dagger: Dagger
268
277
  hand_crossbow: Hand Crossbow
@@ -273,6 +282,7 @@ en:
273
282
  scimitar: Scimitar
274
283
  shortbow: Shortbow
275
284
  shortsword: Shortsword
285
+ warhammer: Warhammer
276
286
  options:
277
287
  automatic_roll: Roll Dice for me automatically (Computer will roll for you)
278
288
  dice_roll: Toggle Manual Dice Roll ...
Binary file
data/races/dwarf.yml ADDED
@@ -0,0 +1,35 @@
1
+ base_speed: 25
2
+ size: medium
3
+ darkvision: 60
4
+ attribute_bonus:
5
+ con: 2
6
+ skills:
7
+ - perception
8
+ languages:
9
+ - common
10
+ - dwarvish
11
+ race_features:
12
+ - dwarven_resilience
13
+ - stonecunning
14
+ weapon_proficiencies:
15
+ - battleaxe
16
+ - handaxe
17
+ - light_hammer
18
+ - warhammer
19
+ tool_proficiencies_choice: 1
20
+ tool_proficiencies:
21
+ - smiths_tools
22
+ - brewers_supplies
23
+ - masons_tools
24
+ subrace:
25
+ hill_dwarf:
26
+ attribute_bonus:
27
+ wis: 1
28
+ race_features:
29
+ - dwarven_toughness
30
+ mountain_dwarf:
31
+ attribute_bonus:
32
+ str: 1
33
+ weapon_proficiencies:
34
+ - light_armor
35
+ - medium_armor
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: natural_20
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Joseph Dayo
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2021-02-06 00:00:00.000000000 Z
11
+ date: 2021-02-08 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bundler
@@ -257,6 +257,7 @@ files:
257
257
  - fixtures/corridors.yml
258
258
  - fixtures/elf_rogue.yml
259
259
  - fixtures/halfling_rogue.yml
260
+ - fixtures/hide_test.yml
260
261
  - fixtures/high_elf_fighter.yml
261
262
  - fixtures/human_fighter.yml
262
263
  - fixtures/path_finding_test.yml
@@ -334,12 +335,14 @@ files:
334
335
  - lib/natural_20/version.rb
335
336
  - locales/en.yml
336
337
  - maps/game_map.yml
338
+ - natural_20-0.1.0.gem
337
339
  - natural_20.gemspec
338
340
  - npcs/goblin.yml
339
341
  - npcs/human_guard.yml
340
342
  - npcs/ogre.yml
341
343
  - npcs/owlbear.yml
342
344
  - npcs/wolf.yml
345
+ - races/dwarf.yml
343
346
  - races/elf.yml
344
347
  - races/halfling.yml
345
348
  - races/human.yml