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 +4 -4
- data/README.md +22 -0
- data/fixtures/hide_test.yml +27 -0
- data/items/objects.yml +23 -0
- data/items/weapons.yml +57 -0
- data/lib/natural_20/actions/attack_action.rb +10 -8
- data/lib/natural_20/actions/first_aid_action.rb +1 -0
- data/lib/natural_20/actions/ground_interact_action.rb +5 -0
- data/lib/natural_20/actions/hide_action.rb +4 -3
- data/lib/natural_20/actions/move_action.rb +3 -26
- data/lib/natural_20/ai_controller/standard.rb +15 -2
- data/lib/natural_20/battle.rb +12 -2
- data/lib/natural_20/battle_map.rb +17 -3
- data/lib/natural_20/cli/character_builder.rb +11 -2
- data/lib/natural_20/cli/commandline_ui.rb +6 -5
- data/lib/natural_20/cli/map_renderer.rb +15 -3
- data/lib/natural_20/concerns/entity.rb +3 -5
- data/lib/natural_20/concerns/movement_helper.rb +33 -1
- data/lib/natural_20/concerns/navigation.rb +2 -1
- data/lib/natural_20/item_library/object.rb +15 -0
- data/lib/natural_20/player_character.rb +17 -7
- data/lib/natural_20/utils/cover.rb +7 -0
- data/lib/natural_20/version.rb +1 -1
- data/locales/en.yml +12 -2
- data/natural_20-0.1.0.gem +0 -0
- data/races/dwarf.yml +35 -0
- metadata +5 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: e33ce92d3b1d8ebf75e0262bb56ab3e990702897632c2ebdb476e814d8abfe4a
|
4
|
+
data.tar.gz: c3aa5ca94f3d587466f4ec93fd44c05a58af2f009cd58cdf50f36b9e9be1bd75
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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
|
-
|
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.
|
114
|
+
battle.consume(item[:source], :reaction)
|
113
115
|
elsif item[:second_hand]
|
114
|
-
battle.
|
116
|
+
battle.consume(item[:source], :bonus_action)
|
115
117
|
else
|
116
|
-
battle.
|
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.
|
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
|
41
|
-
battle.
|
41
|
+
if item[:bonus_action]
|
42
|
+
battle.consume(item[:source], :bonus_action)
|
42
43
|
else
|
43
|
-
battle.
|
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
|
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
|
data/lib/natural_20/battle.rb
CHANGED
@@ -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
|
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(
|
489
|
-
|
490
|
-
entity.available_actions(@session, battle).each do |
|
491
|
-
menu.choice
|
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
|
-
|
22
|
-
|
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
|
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.
|
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
|
-
|
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
|
-
|
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
|
247
|
-
|
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:
|
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 =
|
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
|
|
data/lib/natural_20/version.rb
CHANGED
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.
|
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-
|
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
|