terminal_hero 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/bin/terminal_hero +3 -0
- data/lib/terminal_hero.rb +5 -0
- data/lib/terminal_hero/classes/creature.rb +80 -0
- data/lib/terminal_hero/classes/errors/invalid_input_error.rb +12 -0
- data/lib/terminal_hero/classes/errors/no_feature_error.rb +9 -0
- data/lib/terminal_hero/classes/map.rb +247 -0
- data/lib/terminal_hero/classes/monster.rb +84 -0
- data/lib/terminal_hero/classes/player.rb +98 -0
- data/lib/terminal_hero/classes/stat_menu.rb +76 -0
- data/lib/terminal_hero/classes/tile.rb +45 -0
- data/lib/terminal_hero/modules/display_controller.rb +239 -0
- data/lib/terminal_hero/modules/game_controller.rb +311 -0
- data/lib/terminal_hero/modules/game_data.rb +331 -0
- data/lib/terminal_hero/modules/input_handler.rb +29 -0
- data/lib/terminal_hero/modules/utils.rb +38 -0
- metadata +60 -0
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,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
|