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 +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
|