natural_20 0.1.0 → 0.1.1

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