terminal_hero 0.1.0

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