terminal_hero 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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