terminal_hero 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: eeb49e0681f62399bacf4cd684adf8082d49fe1220ab74a1f4ea5591b1838acc
4
+ data.tar.gz: 31077f1a8e99df94e28bb9dfbde8aaa6e5c6531e04dbd2dc943a2c2011f25f97
5
+ SHA512:
6
+ metadata.gz: 6df974bec975cb17a01c02ef7d14aa15b6ec81018be52ec6642947609bd249a84f7647d075f742707a1da67482642d8a3dcf131a66031e166cf698b3d35b255b
7
+ data.tar.gz: b19dfd9b206ad1528db9e8967a49006784fa8f190e3025f7a656e75f18bf74dc6de2608bc2c4014713ae9835fe754b2fd8e24a332ad5feacafa668232d08ee23
data/bin/terminal_hero ADDED
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env ruby
2
+ require 'terminal_hero'
3
+ ruby terminal_hero.rb
@@ -0,0 +1,5 @@
1
+ require_relative "terminal_hero/modules/game_controller"
2
+
3
+ next_state = GameController.start_game(ARGV)
4
+ next_state = GameController.enter(*next_state) until next_state == :exit_game
5
+ GameController.exit_game
@@ -0,0 +1,80 @@
1
+ require_relative "../modules/game_data"
2
+ require_relative "../modules/utils"
3
+
4
+ # Represents a creature that can participate in combat
5
+ # (ie. Player and Monsters)
6
+ class Creature
7
+ attr_accessor :coords
8
+ attr_reader :max_hp, :current_hp, :stats, :level, :name, :avatar
9
+
10
+ def initialize(name, coords, stats, health_lost, level, avatar)
11
+ @name = name
12
+ @level = level
13
+ @stats = Utils.depth_two_clone(stats)
14
+ @max_hp = calc_max_hp
15
+ @current_hp = @max_hp - health_lost
16
+ @coords = coords
17
+ @avatar = avatar
18
+ end
19
+
20
+ # Given a direction to move, return the destination coords
21
+ def calc_destination(direction)
22
+ return nil if direction.nil?
23
+
24
+ return {
25
+ x: @coords[:x] + GameData::MOVE_KEYS[direction][:x],
26
+ y: @coords[:y] + GameData::MOVE_KEYS[direction][:y]
27
+ }
28
+ end
29
+
30
+ # Calculate max HP based on stats (constitution)
31
+ def calc_max_hp
32
+ return @stats[:con][:value] * GameData::CON_TO_HP
33
+ end
34
+
35
+ # Calculate damage range based on a given attack stat value,
36
+ # returning {min: min, max: max}
37
+ def calc_damage_range(attack: stats[:atk][:value])
38
+ return { min: attack, max: (attack * 1.5).round }
39
+ end
40
+
41
+ # Determine damage within a range based on a random (or given) roll
42
+ def calc_damage_dealt(min: calc_damage_range[:min], max: calc_damage_range[:max])
43
+ return rand(min..max)
44
+ end
45
+
46
+ # Reduce hp by damage taken, after applying defence stat, but not below 0
47
+ def receive_damage(base_damage, defence: @stats[:dfc][:value])
48
+ reduction = (defence.to_f / 2).round
49
+ damage = [base_damage - reduction, 1].max
50
+ @current_hp = [@current_hp - damage, 0].max
51
+ return damage
52
+ end
53
+
54
+ # Increase hp by healing received, not exceeding max hp
55
+ def heal_hp(healing)
56
+ @current_hp = [@current_hp + healing, @max_hp].min
57
+ return healing
58
+ end
59
+
60
+ # Attempt to flee from an enemy in combat. Chance of success varies with level difference
61
+ def flee(enemy)
62
+ level_difference = @level - enemy.level
63
+ target = Utils.collar(0.05, 0.5 - (level_difference / 10.0), 0.95)
64
+ return rand >= target
65
+ end
66
+
67
+ # Returns true if the creature is dead (hp at or below zero)
68
+ def dead?
69
+ return @current_hp <= 0
70
+ end
71
+
72
+ # Returns the color to use in displaying the creature's health based on its percentage of max hp
73
+ def health_color
74
+ health_percent = @current_hp.to_f / @max_hp * 100
75
+ return :green if health_percent > 60
76
+ return :light_yellow if health_percent > 25
77
+
78
+ return :red
79
+ end
80
+ end
@@ -0,0 +1,12 @@
1
+ # A custom error raised when providing user input that does not meet validation requirements
2
+ class InvalidInputError < StandardError
3
+ def initialize(msg: "Invalid input provided.", requirements: "Input must meet validation requirements.")
4
+ super(msg)
5
+ @msg = msg
6
+ @requirements = requirements
7
+ end
8
+ def to_s
9
+ super
10
+ "#{@msg} #{@requirements}"
11
+ end
12
+ end
@@ -0,0 +1,9 @@
1
+ # A custom error raised when accessing a feature not yet implemented
2
+ class NoFeatureError < StandardError
3
+ def initialize(
4
+ msg = "Sorry, it looks like you're trying to access a feature that hasn't been implemented yet. "\
5
+ "Try choosing something else!"
6
+ )
7
+ super
8
+ end
9
+ end
@@ -0,0 +1,247 @@
1
+ begin
2
+ require "colorize"
3
+ rescue LoadError => e
4
+ # Display load errors using puts (not calling external methods which may not be available)
5
+ puts "It appears that a dependency was unable to be loaded: "
6
+ puts e.message
7
+ puts "Please try installing dependencies mannually by running the command "\
8
+ "\"bundle install\" from within the installation directory."
9
+ puts "If you installed this application as a gem, you could try reinstalling it by "\
10
+ "running \"gem uninstall terminal_hero\" followed by \"gem install terminal_hero\""
11
+ exit
12
+ end
13
+ require_relative "tile"
14
+ require_relative "monster"
15
+ require_relative "../modules/game_data"
16
+
17
+ # Represents a map for the player to navigate
18
+ class Map
19
+ include GameData
20
+
21
+ attr_reader :grid, :symbols
22
+
23
+ def initialize(player: nil, width: GameData::MAP_WIDTH, height: GameData::MAP_HEIGHT, grid: nil, monsters: [])
24
+ # Set dimensions of map
25
+ @width = width
26
+ @height = height
27
+ # Dictionary of map symbols
28
+ @symbols = GameData::MAP_TILES
29
+ # Array of monsters on the map
30
+ @monsters = monsters
31
+ if grid.nil?
32
+ generate_map(player)
33
+ else
34
+ load_map(grid, player)
35
+ end
36
+ end
37
+
38
+ # SETUP
39
+
40
+ # Generate a new map when starting a new game
41
+ def generate_map(player)
42
+ # Fill map grid with terrain tiles
43
+ @grid = setup_grid
44
+ # Place the Player on the map
45
+ @grid[player.coords[:y]][player.coords[:x]].entity = player
46
+ # Populate the map with monsters
47
+ populate_monsters(player.level)
48
+ end
49
+
50
+ # Randomly populate monsters on the grid up to a fluctuating maximum population
51
+ def populate_monsters(player_level)
52
+ # 1/60 map tiles +/- 5 will be populated with monsters
53
+ max_monsters = [(@width * @height / 80) + rand(-5..5), 1].max
54
+ # Populate map until max population is reached, or number of iterations equals
55
+ # number of map tiles (preventing infinite loop if valid tile not found)
56
+ counter = 0
57
+ until @monsters.length >= max_monsters || counter >= @width * @height
58
+ y = rand(1..(@height - 2))
59
+ x = rand(1..(@width - 2))
60
+ unless @grid[y][x].blocking
61
+ monster = Monster.new(coords: { x: x, y: y }, level_base: player_level)
62
+ @grid[y][x].entity = monster
63
+ @monsters.push(monster)
64
+ end
65
+ counter += 1
66
+ end
67
+ end
68
+
69
+ # Given a grid from save data, load it into the @grid instance variable
70
+ def load_grid!(grid)
71
+ @grid = grid.map do |row|
72
+ row.map do |tile|
73
+ # Convert string values generated by JSON back to symbols
74
+ tile[:color] = tile[:color].to_sym
75
+ tile[:event] = tile[:event].to_sym unless tile[:event].nil?
76
+ # Map each hash of tile data to a Tile
77
+ Tile.new(**tile)
78
+ end
79
+ end
80
+ end
81
+
82
+ # Load monsters from save data into @monsters and place them on tiles in @grid
83
+ def load_monsters!
84
+ @monsters.map! do |monster_data|
85
+ monster_data[:event] = monster_data[:event].to_sym
86
+ monster = Monster.new(**monster_data)
87
+ @grid[monster.coords[:y]][monster.coords[:x]].entity = monster
88
+ monster
89
+ end
90
+ end
91
+
92
+ # Set up the map grid using data loaded from a save file when loading the game
93
+ def load_map(grid, player)
94
+ load_grid!(grid)
95
+ load_monsters!
96
+ @grid[player.coords[:y]][player.coords[:x]].entity = player
97
+ end
98
+
99
+ # Given indices, centrepoints, a radius, and a modification to the radius,
100
+ # determine whether indices fall inside the radius from the centrepoints. Used for generating terrain.
101
+ def in_radius?(indices, centrepoints, radii, variance)
102
+ y_index, x_index = indices
103
+ y_centre, x_centre = centrepoints
104
+ v_radius, h_radius = radii
105
+ ((y_centre - v_radius - variance)..(y_centre + v_radius + variance)).include?(y_index) &&
106
+ ((x_centre - h_radius - variance)..(x_centre + h_radius + variance)).include?(x_index)
107
+ end
108
+
109
+ # Calculate relevant paramaters for setup_grid
110
+ def generate_grid_params
111
+ # Create 2D array grid
112
+ grid = []
113
+ @height.times { grid.push(Array.new(@width, false)) }
114
+
115
+ # Return parameters for map generation - centrepoints, base radius of map
116
+ # regions, and the maximum random variance from that radius
117
+ return grid, @width / 2, @height / 2, @width / 8, @height / 8, [@width, @height].min / 16
118
+ end
119
+
120
+ # Populate the map grid with terrain tiles in a semi-random distribution of
121
+ # regions expanding from the centre of the map outwards
122
+ def setup_grid
123
+ grid, h_cent, v_cent, h_rad, v_rad, max_variance = generate_grid_params
124
+ variance = 0
125
+ # Populate the map grid with terrain tiles
126
+ grid.each_with_index do |row, y|
127
+ row.map!.with_index do |_square, x|
128
+ # First and last row and column are edge tiles
129
+ if y == 0 || y == @height - 1 || x == 0 || x == @width - 1
130
+ tile = Tile.new(**@symbols[:edge])
131
+ # Tiles inside base radius (after variance) are region 1
132
+ elsif in_radius?([y, x], [v_cent, h_cent], [v_rad, h_rad], variance)
133
+ tile = Tile.new(**@symbols[:mountain])
134
+ # Tiles not in region 1 that are inside 2 * base radius are region 2
135
+ elsif in_radius?([y, x], [v_cent, h_cent], [v_rad * 2, h_rad * 2], variance)
136
+ tile = Tile.new(**@symbols[:forest])
137
+ # Everything else is region 3
138
+ else
139
+ tile = Tile.new(**@symbols[:plain])
140
+ end
141
+ # Change the variance applied to radius so region boundaries are irregular
142
+ variance = Utils.collar(0, variance + rand(-1..1), max_variance)
143
+ tile
144
+ end
145
+ end
146
+ return grid
147
+ end
148
+
149
+ # MOVEMENT PROCESSING
150
+
151
+ # Given destination coords for movement, update the map, move the moving entity
152
+ # and return the destination tile
153
+ def process_movement(mover, destination)
154
+ # If player destination is out of bounds, display and log error, then exit
155
+ begin
156
+ raise InvalidInputError if !valid_move?(destination) && mover.instance_of?(Player)
157
+ rescue InvalidInputError => e
158
+ DisplayController
159
+ .display_messages(GameData::MESSAGES[:general_error]
160
+ .call("Movement", e, Utils.log_error(e), msg: GameData::MESSAGES[:out_of_bounds_error]))
161
+ exit
162
+ end
163
+ # Return nil if monster tries to move out of bounds
164
+ return nil unless valid_move?(destination)
165
+
166
+ # Process move and if destination is not blocked and return destination tile
167
+ unless @grid[destination[:y]][destination[:x]].blocking
168
+ @grid[mover.coords[:y]][mover.coords[:x]].entity = nil
169
+ @grid[destination[:y]][destination[:x]].entity = mover
170
+ mover.coords = destination
171
+ end
172
+ return @grid[destination[:y]][destination[:x]]
173
+ end
174
+
175
+ # Call methods for each monster to determine the move it makes (if any) and process
176
+ # that movement. If a monster encounters the player, return its tile to allow
177
+ # triggering a combat event.
178
+ def move_monsters(player_coords)
179
+ event_tile = nil
180
+ @monsters.each do |monster|
181
+ destination = monster.calc_destination(monster.choose_move(player_coords))
182
+ process_movement(monster, destination)
183
+ event_tile = @grid[monster.coords[:y]][monster.coords[:x]] if destination == player_coords
184
+ end
185
+ return event_tile
186
+ end
187
+
188
+ # Check if coords are a valid destination within the map (but not necessarily open for movement)
189
+ def valid_move?(coords)
190
+ return false unless coords.is_a?(Hash)
191
+ return false unless (0..(@width - 1)).include?(coords[:x])
192
+ return false unless (0..(@height - 1)).include?(coords[:y])
193
+
194
+ return true
195
+ end
196
+
197
+ # COMBAT OUTCOME PROCESSING
198
+
199
+ # If player was defeated in combat, move them back to starting location (unless
200
+ # already there), swapping positions with any entity that is currently occupying that location
201
+ def process_combat_defeat(player)
202
+ return if player.coords.values == GameData::DEFAULT_COORDS.values
203
+
204
+ shifted_entity = @grid[GameData::DEFAULT_COORDS[:y]][GameData::DEFAULT_COORDS[:x]].entity
205
+ player_location = player.coords
206
+ @grid[GameData::DEFAULT_COORDS[:y]][GameData::DEFAULT_COORDS[:x]].entity = nil
207
+ process_movement(player, GameData::DEFAULT_COORDS)
208
+ process_movement(shifted_entity, player_location) unless shifted_entity.nil?
209
+ end
210
+
211
+
212
+ # Remove a monster from the map
213
+ def remove_monster(monster)
214
+ @grid[monster.coords[:y]][monster.coords[:x]].entity = nil
215
+ @monsters.delete(monster)
216
+ end
217
+
218
+ # If a monster was defeated in combat, remove it and repopulate monsters
219
+ def process_combat_victory(player, monster)
220
+ remove_monster(monster)
221
+ populate_monsters(player.level)
222
+ end
223
+
224
+ # When combat ends, call methods to update the map based on the outcome
225
+ def post_combat(player, monster, outcome)
226
+ case outcome
227
+ when :victory
228
+ process_combat_victory(player, monster)
229
+ when :defeat
230
+ process_combat_defeat(player)
231
+ end
232
+ end
233
+
234
+ # EXPORT FOR SAVE
235
+
236
+ # Export all values required for map initialization to a hash, to be stored in a JSON save file
237
+ def export
238
+ return {
239
+ width: @width,
240
+ height: @height,
241
+ grid: @grid.map do |row|
242
+ row.map(&:export)
243
+ end,
244
+ monsters: @monsters.map(&:export)
245
+ }
246
+ end
247
+ end
@@ -0,0 +1,84 @@
1
+ require_relative "creature"
2
+ require_relative "../modules/game_data"
3
+ require_relative "../modules/utils"
4
+
5
+ # Represents an enemy that the player can fight
6
+ class Monster < Creature
7
+ attr_reader :event
8
+
9
+ def initialize(
10
+ name: "Monster",
11
+ coords: nil,
12
+ stats: GameData::DEFAULT_STATS,
13
+ health_lost: 0,
14
+ level_base: 1,
15
+ level: nil,
16
+ avatar: "%".colorize(:red),
17
+ event: :combat
18
+ )
19
+ level = select_level(level_base) if level.nil?
20
+ stats = allocate_stats(stats, level)
21
+ super(name, coords, stats, health_lost, level, avatar)
22
+ @event = event
23
+ end
24
+
25
+ # Set level, based on base level and maximum deviation from that base
26
+ def select_level(level_base)
27
+ min = [level_base - GameData::MONSTER_LEVEL_VARIANCE, 1].max
28
+ max = [level_base + GameData::MONSTER_LEVEL_VARIANCE, 1].max
29
+ return rand(min..max)
30
+ end
31
+
32
+ # Calculate stat points based on monster level, and randomly allocate them among stats
33
+ def allocate_stats(starting_stats, level)
34
+ # Monster starts with 5 less stat points, so Player is slightly stronger
35
+ stat_points = (level - 1) * GameData::STAT_POINTS_PER_LEVEL
36
+ stats = Utils.depth_two_clone(starting_stats)
37
+ # Allocate a random number of available points to each stat, in random order
38
+ keys = stats.keys.shuffle
39
+ keys.each do |key|
40
+ point_spend = rand(0..stat_points)
41
+ stats[key][:value] += point_spend
42
+ stat_points -= point_spend
43
+ end
44
+ # Allocate remaining stat points to the last stat
45
+ stats[keys[-1]][:value] += stat_points
46
+ return stats
47
+ end
48
+
49
+ # Calculate the amount of XP a monster is worth, based on its level and
50
+ # an exponent and range
51
+ def calc_xp(level: @level, exponent: GameData::LEVELING_EXPONENT, constant: level)
52
+ return constant + (level**exponent).round
53
+ end
54
+
55
+ # Decide whether and where to move. There is a 75% chance the monster will move at all, and if it
56
+ # does, it will move towards the palayer if the player is within 6 tiles of the moster.
57
+ def choose_move(player_coords)
58
+ return nil unless rand < 0.75
59
+
60
+ x_difference = @coords[:x] - player_coords[:x]
61
+ y_difference = @coords[:y] - player_coords[:y]
62
+ directions = { x: [:left, :right], y: [:up, :down] }
63
+ if x_difference.abs + y_difference.abs <= 6
64
+ axis = x_difference.abs > y_difference.abs ? :x : :y
65
+ direction = @coords[axis] > player_coords[axis] ? directions[axis][0] : directions[axis][1]
66
+ else
67
+ direction = [:left, :right, :up, :down][rand(0..3)]
68
+ end
69
+ return direction
70
+ end
71
+
72
+ # Export all values required for initialization to a hash, to be stored in a JSON save file
73
+ def export
74
+ return {
75
+ name: @name,
76
+ coords: @coords,
77
+ level: @level,
78
+ stats: @stats,
79
+ health_lost: (@max_hp - @current_hp),
80
+ avatar: @avatar,
81
+ event: @event
82
+ }
83
+ end
84
+ end