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