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