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
@@ -0,0 +1,98 @@
|
|
1
|
+
require_relative "../modules/game_data"
|
2
|
+
require_relative "../classes/creature"
|
3
|
+
|
4
|
+
# Represents the player's character
|
5
|
+
class Player < Creature
|
6
|
+
attr_accessor :stats
|
7
|
+
attr_reader :current_xp
|
8
|
+
|
9
|
+
def initialize(
|
10
|
+
name: "Player",
|
11
|
+
coords: GameData::DEFAULT_COORDS,
|
12
|
+
level: 1,
|
13
|
+
stats: GameData::DEFAULT_STATS,
|
14
|
+
health_lost: 0,
|
15
|
+
current_xp: 0,
|
16
|
+
avatar: "@".colorize(:blue)
|
17
|
+
)
|
18
|
+
super(name, coords, stats, health_lost, level, avatar)
|
19
|
+
@current_xp = current_xp
|
20
|
+
end
|
21
|
+
|
22
|
+
# Given a level, calculate the XP required to level up
|
23
|
+
def calc_xp_to_level(current_lvl: @level, constant: GameData::LEVELING_CONSTANT, exponent: GameData::LEVELING_EXPONENT)
|
24
|
+
return (constant * (current_lvl**exponent)).round
|
25
|
+
end
|
26
|
+
|
27
|
+
# Apply any healing and XP gain or loss after the end of a combat encounter,
|
28
|
+
# based on the outcome of the combat and the enemy fought. Return xp gained or lost (if any) for display to the user.
|
29
|
+
def post_combat(outcome, enemy)
|
30
|
+
case outcome
|
31
|
+
when :victory
|
32
|
+
return gain_xp(enemy.calc_xp)
|
33
|
+
when :defeat
|
34
|
+
heal_hp(@max_hp)
|
35
|
+
return lose_xp
|
36
|
+
else
|
37
|
+
return nil
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
# Gain a given amount of XP, and return the amount gained
|
42
|
+
def gain_xp(xp_gained)
|
43
|
+
@current_xp += xp_gained
|
44
|
+
return xp_gained
|
45
|
+
end
|
46
|
+
|
47
|
+
# Lose an amount of XP based on player level (but not reducing current XP below
|
48
|
+
# 0), and return the amount lost
|
49
|
+
def lose_xp(level: @level, exponent: GameData::LEVELING_EXPONENT, constant: level, modifier: GameData::XP_LOSS_MULTIPLIER)
|
50
|
+
xp_lost = (constant + (level**exponent) * modifier).round
|
51
|
+
@current_xp = [@current_xp - xp_lost, 0].max
|
52
|
+
return xp_lost
|
53
|
+
end
|
54
|
+
|
55
|
+
# Return a string showing Player's progress to next level
|
56
|
+
def xp_progress
|
57
|
+
return "#{@current_xp}/#{calc_xp_to_level}"
|
58
|
+
end
|
59
|
+
|
60
|
+
# Returns whether the player's current xp is sufficient to level up
|
61
|
+
def leveled_up?
|
62
|
+
return @current_xp >= calc_xp_to_level
|
63
|
+
end
|
64
|
+
|
65
|
+
# Levels up the player based on current XP and returns the number of levels gained
|
66
|
+
def level_up
|
67
|
+
return 0 unless leveled_up?
|
68
|
+
|
69
|
+
levels_gained = 0
|
70
|
+
while @current_xp >= calc_xp_to_level
|
71
|
+
@current_xp -= calc_xp_to_level
|
72
|
+
@level += 1
|
73
|
+
levels_gained += 1
|
74
|
+
end
|
75
|
+
return levels_gained
|
76
|
+
end
|
77
|
+
|
78
|
+
# Update the player's stats to reflect a given statblock,
|
79
|
+
# and restore hp to full
|
80
|
+
def allocate_stats(stats)
|
81
|
+
@stats = stats
|
82
|
+
@max_hp = calc_max_hp
|
83
|
+
@current_hp = @max_hp
|
84
|
+
end
|
85
|
+
|
86
|
+
# Export all values required for initialization to a hash, to be stored in a JSON save file
|
87
|
+
def export
|
88
|
+
return {
|
89
|
+
name: @name,
|
90
|
+
coords: @coords,
|
91
|
+
level: @level,
|
92
|
+
stats: @stats,
|
93
|
+
health_lost: (@max_hp - @current_hp),
|
94
|
+
current_xp: @current_xp,
|
95
|
+
avatar: @avatar
|
96
|
+
}
|
97
|
+
end
|
98
|
+
end
|
@@ -0,0 +1,76 @@
|
|
1
|
+
begin
|
2
|
+
require "remedy"
|
3
|
+
rescue LoadError => e
|
4
|
+
# Display load errors using puts (not calling external methods which may not have been loaded)
|
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
|
+
|
14
|
+
require_relative "../modules/utils"
|
15
|
+
# A menu allowing the player to allocate stat points to their character's stats.
|
16
|
+
# Custom class created because tty-prompt does not provide menus in this format.
|
17
|
+
class StatMenu
|
18
|
+
include Remedy
|
19
|
+
def initialize(starting_stats, starting_points)
|
20
|
+
@starting_stats = starting_stats
|
21
|
+
@stats = Utils.depth_two_clone(starting_stats)
|
22
|
+
@starting_points = starting_points
|
23
|
+
@points = starting_points
|
24
|
+
@line_no = 0
|
25
|
+
@header = Header.new
|
26
|
+
@footer = Footer.new
|
27
|
+
@stat_index = @stats.keys
|
28
|
+
@header << "Please allocate your character's stat points."
|
29
|
+
@header << "Use the left and right arrow keys to assign points, and enter to confirm."
|
30
|
+
end
|
31
|
+
|
32
|
+
def process_input(key)
|
33
|
+
case key
|
34
|
+
when :right
|
35
|
+
add_point
|
36
|
+
# Left arrow key reduces highlighted stat, but not below its starting value
|
37
|
+
when :left
|
38
|
+
subtract_point
|
39
|
+
# Up and down arrow keys to move around list
|
40
|
+
when :down, :up
|
41
|
+
change_line(key)
|
42
|
+
# :control_m represents carriage return
|
43
|
+
when :control_m
|
44
|
+
return true, @stats if @points.zero?
|
45
|
+
|
46
|
+
@footer << "You must allocate all stat points to continue.".colorize(:red) if @footer.lines.empty?
|
47
|
+
end
|
48
|
+
return false
|
49
|
+
end
|
50
|
+
|
51
|
+
def add_point
|
52
|
+
return unless @points.positive?
|
53
|
+
|
54
|
+
@points -= 1
|
55
|
+
@stats[@stat_index[@line_no]][:value] += 1
|
56
|
+
end
|
57
|
+
|
58
|
+
def subtract_point
|
59
|
+
unless @points < @starting_points &&
|
60
|
+
@stats[@stat_index[@line_no]][:value] > @starting_stats[@stat_index[@line_no]][:value]
|
61
|
+
return
|
62
|
+
end
|
63
|
+
|
64
|
+
@points += 1
|
65
|
+
@stats[@stat_index[@line_no]][:value] -= 1
|
66
|
+
end
|
67
|
+
|
68
|
+
def change_line(key)
|
69
|
+
change = key == :down ? 1 : -1
|
70
|
+
@line_no = Utils.collar(0, @line_no + change, @stats.length - 1)
|
71
|
+
end
|
72
|
+
|
73
|
+
def get_display_parameters
|
74
|
+
return [@stats, @points, @line_no, @header, @footer]
|
75
|
+
end
|
76
|
+
end
|
@@ -0,0 +1,45 @@
|
|
1
|
+
# Represents a terrain or entity tile on the map
|
2
|
+
class Tile
|
3
|
+
attr_accessor :blocking, :event
|
4
|
+
attr_reader :symbol, :entity
|
5
|
+
|
6
|
+
def initialize(symbol: "?", color: :default, blocking: false, event: nil, entity: nil)
|
7
|
+
@symbol = symbol.colorize(color)
|
8
|
+
@color = color
|
9
|
+
@blocking = blocking
|
10
|
+
@event = event
|
11
|
+
@entity = entity
|
12
|
+
end
|
13
|
+
|
14
|
+
# If the tile is unoccupied, return its display icon. Otherwise, return the occupant's icon.
|
15
|
+
def to_s
|
16
|
+
return @symbol if @entity.nil?
|
17
|
+
|
18
|
+
return @entity.avatar
|
19
|
+
end
|
20
|
+
|
21
|
+
# Customer setter to set (or remove) the entity occupying a tile and update
|
22
|
+
# the tile's properties accordingly
|
23
|
+
def entity=(new_entity)
|
24
|
+
if new_entity.nil?
|
25
|
+
@entity = nil
|
26
|
+
@event = nil
|
27
|
+
@blocking = false
|
28
|
+
else
|
29
|
+
@entity = new_entity
|
30
|
+
@blocking = true
|
31
|
+
@event = new_entity.respond_to?(:event) ? new_entity.event : nil
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
# Export all values required for initialization to a hash, to be stored in a JSON save file
|
36
|
+
def export
|
37
|
+
return {
|
38
|
+
symbol: @symbol,
|
39
|
+
color: @color,
|
40
|
+
blocking: @blocking,
|
41
|
+
event: @event,
|
42
|
+
entity: @entity.nil? || @entity.instance_of?(Player) ? nil : @entity.export
|
43
|
+
}
|
44
|
+
end
|
45
|
+
end
|
@@ -0,0 +1,239 @@
|
|
1
|
+
begin
|
2
|
+
require "remedy"
|
3
|
+
require "tty-prompt"
|
4
|
+
require "tty-font"
|
5
|
+
rescue LoadError => e
|
6
|
+
# Display load errors using puts (not calling external methods which may not have been loaded)
|
7
|
+
puts "It appears that a dependency was unable to be loaded: "
|
8
|
+
puts e.message
|
9
|
+
puts "Please try installing dependencies mannually by running the command "\
|
10
|
+
"\"bundle install\" from within the installation directory."
|
11
|
+
puts "If you installed this application as a gem, you could try reinstalling it by "\
|
12
|
+
"running \"gem uninstall terminal_hero\" followed by \"gem install terminal_hero\""
|
13
|
+
exit
|
14
|
+
end
|
15
|
+
|
16
|
+
require_relative "game_data"
|
17
|
+
require_relative "utils"
|
18
|
+
require_relative "input_handler"
|
19
|
+
require_relative "../classes/errors/invalid_input_error"
|
20
|
+
require_relative "../classes/stat_menu"
|
21
|
+
|
22
|
+
# Controls the display of output to the user
|
23
|
+
module DisplayController
|
24
|
+
include Remedy
|
25
|
+
|
26
|
+
# Displays a message in an ASCII art font. Return the prompt object for use
|
27
|
+
# with subsequent prompts.
|
28
|
+
def self.display_ascii(msg)
|
29
|
+
clear
|
30
|
+
font = TTY::Font.new(:standard)
|
31
|
+
prompt = TTY::Prompt.new
|
32
|
+
prompt.say(msg.call(font, Console.size))
|
33
|
+
return prompt
|
34
|
+
end
|
35
|
+
|
36
|
+
# Displays the title menu
|
37
|
+
def self.prompt_title_menu
|
38
|
+
prompt = display_ascii(GameData::ASCII_ART[:title])
|
39
|
+
return prompt.select("What would you like to do?", GameData::TITLE_MENU_OPTIONS)
|
40
|
+
end
|
41
|
+
|
42
|
+
# Display a series of messages, waiting for keypress input to advance
|
43
|
+
def self.display_messages(msgs, pause: true)
|
44
|
+
prompt = TTY::Prompt.new(quiet: true)
|
45
|
+
print "\n"
|
46
|
+
msgs.each do |msg|
|
47
|
+
puts msg
|
48
|
+
print "\n"
|
49
|
+
prompt.keypress("Press any key...") if pause
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
# Prompt the user to enter a character name when creating a character
|
54
|
+
def self.prompt_character_name
|
55
|
+
begin
|
56
|
+
prompt = display_ascii(GameData::ASCII_ART[:title])
|
57
|
+
name = prompt.ask("Please enter a name for your character: ")
|
58
|
+
unless InputHandler.character_name_valid?(name)
|
59
|
+
raise InvalidInputError.new(requirements: GameData::VALIDATION_REQUIREMENTS[:character_name])
|
60
|
+
end
|
61
|
+
rescue InvalidInputError => e
|
62
|
+
display_messages([e.message.colorize(:red), "Please try again.".colorize(:red)])
|
63
|
+
retry
|
64
|
+
end
|
65
|
+
return name
|
66
|
+
end
|
67
|
+
|
68
|
+
# Prompt the user for whether to re-try a failed action
|
69
|
+
def self.prompt_yes_no(msg, default_no: false)
|
70
|
+
TTY::Prompt.new.select(msg) do |menu|
|
71
|
+
menu.default default_no ? "No" : "Yes"
|
72
|
+
menu.choice "Yes", true
|
73
|
+
menu.choice "No", false
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
# Ask the user whether they would like to view the tutorial
|
78
|
+
def self.prompt_tutorial(repeat: false)
|
79
|
+
display_ascii(GameData::ASCII_ART[:title])
|
80
|
+
verb = repeat ? "repeat" : "see"
|
81
|
+
message = "Would you like to #{verb} the tutorial?"
|
82
|
+
return prompt_yes_no(message, default_no: repeat)
|
83
|
+
end
|
84
|
+
|
85
|
+
# Prompt the user to enter the name of the character they want to attempt to load
|
86
|
+
def self.prompt_save_name(name = nil)
|
87
|
+
display_ascii(GameData::ASCII_ART[:title])
|
88
|
+
begin
|
89
|
+
name = TTY::Prompt.new.ask("Please enter the name of the character you want to load: ") if name.nil?
|
90
|
+
unless InputHandler.character_name_valid?(name)
|
91
|
+
raise InvalidInputError.new(requirements: GameData::VALIDATION_REQUIREMENTS[:character_name])
|
92
|
+
end
|
93
|
+
rescue InvalidInputError => e
|
94
|
+
display_messages([e.message.colorize(:red)])
|
95
|
+
return false unless prompt_yes_no(GameData::PROMPTS[:re_load])
|
96
|
+
|
97
|
+
name = nil
|
98
|
+
retry
|
99
|
+
end
|
100
|
+
return name
|
101
|
+
end
|
102
|
+
|
103
|
+
# Display the stat point allocation menu to the user
|
104
|
+
def self.display_stat_menu(stats, points, line_no, header, footer)
|
105
|
+
screen = Viewport.new
|
106
|
+
menu = Content.new
|
107
|
+
lines = stats.values.map { |stat| "#{stat[:name]}: #{stat[:value]}" }
|
108
|
+
lines[line_no] = lines[line_no].colorize(:light_blue)
|
109
|
+
menu.lines.push " ", "Stat points remaining: #{points}", " "
|
110
|
+
lines.each { |line| menu << line }
|
111
|
+
screen.draw(menu, [0, 0], header, footer)
|
112
|
+
end
|
113
|
+
|
114
|
+
# Prompt the user and get their input to allocate stat points using a stat menu
|
115
|
+
def self.prompt_stat_allocation(
|
116
|
+
starting_stats: GameData::DEFAULT_STATS,
|
117
|
+
starting_points: GameData::STAT_POINTS_PER_LEVEL
|
118
|
+
)
|
119
|
+
stat_menu = StatMenu.new(starting_stats, starting_points)
|
120
|
+
display_stat_menu(*stat_menu.get_display_parameters)
|
121
|
+
input = Interaction.new
|
122
|
+
input.loop do |key|
|
123
|
+
finished, stats = stat_menu.process_input(key.name)
|
124
|
+
return stats if finished
|
125
|
+
|
126
|
+
display_stat_menu(*stat_menu.get_display_parameters)
|
127
|
+
end
|
128
|
+
end
|
129
|
+
|
130
|
+
# Set the map render distance to fit within a given console size
|
131
|
+
def self.calc_view_distance(size: Console.size)
|
132
|
+
horizontal = Utils.collar(2, size.cols / 4 - 2, GameData::MAX_H_VIEW_DIST)
|
133
|
+
vertical = Utils.collar(2, size.rows / 2 - 5, GameData::MAX_V_VIEW_DIST)
|
134
|
+
return [horizontal, vertical]
|
135
|
+
end
|
136
|
+
|
137
|
+
# Initialise variables required for draw_map
|
138
|
+
def self.setup_map_view(player)
|
139
|
+
screen = Viewport.new
|
140
|
+
header = Header.new
|
141
|
+
map_display = Content.new
|
142
|
+
GameData::MAP_HEADER.call(player).each { |line| header.lines.push(line) }
|
143
|
+
return [screen, header, map_display]
|
144
|
+
end
|
145
|
+
|
146
|
+
# Given a grid, camera co-ordinates and view distances, return
|
147
|
+
# a grid containing only squares within the camera's field of view
|
148
|
+
def self.filter_visible(grid, camera_coords, size: Console.size, view_dist: calc_view_distance(size: size))
|
149
|
+
h_view_dist, v_view_dist = view_dist
|
150
|
+
# Filter rows outside view distance
|
151
|
+
field_of_view = grid.map do |row|
|
152
|
+
row.reject.with_index { |_cell, x_index| (camera_coords[:x] - x_index).abs > h_view_dist }
|
153
|
+
end
|
154
|
+
# Filter columns outside view distance
|
155
|
+
field_of_view.reject!.with_index { |_row, y_index| (camera_coords[:y] - y_index).abs > v_view_dist }
|
156
|
+
return field_of_view
|
157
|
+
end
|
158
|
+
|
159
|
+
# Calculate the amount of padding required to center a map view
|
160
|
+
def self.calculate_padding(header, content, size)
|
161
|
+
# Top padding is half of the console height minus the view height
|
162
|
+
top_pad = (size.rows - (header.lines.length + content.lines.length)) / 2
|
163
|
+
# Get length of the longest line of the view (to determine view width)
|
164
|
+
view_width = [
|
165
|
+
header.lines.map(&:uncolorize).max_by(&:length).length,
|
166
|
+
content.lines.map(&:uncolorize).max_by(&:length).length
|
167
|
+
].max
|
168
|
+
# Left padding is half of the console width minus the view width.
|
169
|
+
left_pad = (size.cols - view_width) / 2
|
170
|
+
return top_pad, left_pad
|
171
|
+
end
|
172
|
+
|
173
|
+
# Given a header, content and console size, pad the header and content to center them in the console.
|
174
|
+
def self.center_view!(header, content, size)
|
175
|
+
top_pad, left_pad = calculate_padding(header, content, size)
|
176
|
+
top_pad.times { header.lines.unshift(" ") }
|
177
|
+
content.lines.map! { |line| "#{' ' * left_pad}#{line}" }
|
178
|
+
header.lines.map! { |line| "#{' ' * left_pad}#{line}" }
|
179
|
+
end
|
180
|
+
|
181
|
+
# Draws one frame of the visible portion of the map
|
182
|
+
def self.draw_map(map, player, size: Console.size, view_dist: calc_view_distance(size: size))
|
183
|
+
screen, header, map_display = setup_map_view(player)
|
184
|
+
filter_visible(map.grid, player.coords).each do |row|
|
185
|
+
map_display << row.join(" ")
|
186
|
+
end
|
187
|
+
# Pushing additional row prevents truncation in smaller terminal sizes
|
188
|
+
map_display << " " * (view_dist[0] * 2)
|
189
|
+
center_view!(header, map_display, size)
|
190
|
+
screen.draw(map_display, Size.new(0, 0), header)
|
191
|
+
end
|
192
|
+
|
193
|
+
# Sets a hook to draw the map (with adjusted view distance) when the console
|
194
|
+
# is resized
|
195
|
+
def self.set_resize_hook(map, player)
|
196
|
+
Console.set_console_resized_hook! do |size|
|
197
|
+
draw_map(map, player, size: size)
|
198
|
+
end
|
199
|
+
end
|
200
|
+
|
201
|
+
# Cancel the console resize hook (eg. when leaving the map view)
|
202
|
+
def self.cancel_resize_hook
|
203
|
+
Console::Resize.default_console_resized_hook!
|
204
|
+
end
|
205
|
+
|
206
|
+
# Display the combat action selection menu and return user's selection
|
207
|
+
def self.prompt_combat_action(player, enemy)
|
208
|
+
clear
|
209
|
+
display_messages(GameData::MESSAGES[:combat_status].call(player, enemy), pause: false)
|
210
|
+
prompt = TTY::Prompt.new
|
211
|
+
answer = prompt.select("\nWhat would you like to do?", GameData::COMBAT_MENU_OPTIONS)
|
212
|
+
print "\n"
|
213
|
+
return answer
|
214
|
+
end
|
215
|
+
|
216
|
+
# Display relevant information to the user after the end of a combat encounter.
|
217
|
+
def self.post_combat(outcome, player, xp_amount)
|
218
|
+
case outcome
|
219
|
+
when :victory
|
220
|
+
display_messages(GameData::MESSAGES[:combat_victory].call(xp_amount))
|
221
|
+
when :defeat
|
222
|
+
display_messages(GameData::MESSAGES[:combat_defeat].call(xp_amount))
|
223
|
+
display_messages(GameData::MESSAGES[:level_progress].call(player))
|
224
|
+
when :escaped
|
225
|
+
display_messages(GameData::MESSAGES[:combat_escaped])
|
226
|
+
end
|
227
|
+
end
|
228
|
+
|
229
|
+
# When the player levels up, display the number of levels gained
|
230
|
+
def self.level_up(player, levels)
|
231
|
+
display_ascii(GameData::ASCII_ART[:level_up])
|
232
|
+
display_messages(GameData::MESSAGES[:leveled_up].call(player, levels))
|
233
|
+
end
|
234
|
+
|
235
|
+
# Clear the visible terminal display (without clearing terminal history)
|
236
|
+
def self.clear
|
237
|
+
ANSI::Screen.safe_reset!
|
238
|
+
end
|
239
|
+
end
|