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,311 @@
|
|
1
|
+
begin
|
2
|
+
require "remedy"
|
3
|
+
require "json"
|
4
|
+
rescue LoadError => e
|
5
|
+
# Display load errors using puts (not calling external methods which may not have been loaded)
|
6
|
+
puts "It appears that a dependency was unable to be loaded: "
|
7
|
+
puts e.message
|
8
|
+
puts "Please try installing dependencies mannually by running the command "\
|
9
|
+
"\"bundle install\" from within the installation directory."
|
10
|
+
puts "If you installed this application as a gem, you could try reinstalling it by "\
|
11
|
+
"running \"gem uninstall terminal_hero\" followed by \"gem install terminal_hero\""
|
12
|
+
exit
|
13
|
+
end
|
14
|
+
|
15
|
+
require_relative "game_data"
|
16
|
+
require_relative "input_handler"
|
17
|
+
require_relative "display_controller"
|
18
|
+
require_relative "../classes/player"
|
19
|
+
require_relative "../classes/map"
|
20
|
+
require_relative "../classes/errors/no_feature_error"
|
21
|
+
|
22
|
+
# Handles game loops and interactions between main objects
|
23
|
+
module GameController
|
24
|
+
include Remedy
|
25
|
+
|
26
|
+
# MAIN GAME STATES
|
27
|
+
|
28
|
+
# Given a symbol corresponding to a key in the GAME_STATES hash (and optionally
|
29
|
+
# an array of parameters), calls a lambda triggering the method for that game
|
30
|
+
# state (which then returns the next game state + parameters).
|
31
|
+
def self.enter(game_state, params = nil)
|
32
|
+
GameData::GAME_STATES[game_state].call(self, params)
|
33
|
+
end
|
34
|
+
|
35
|
+
# Display an exit message and exit the application
|
36
|
+
def self.exit_game
|
37
|
+
DisplayController.display_messages(GameData::MESSAGES[:exit_game])
|
38
|
+
exit
|
39
|
+
end
|
40
|
+
|
41
|
+
# Display title menu, determine the next game state based on command line
|
42
|
+
# arguments or user input, and return a symbol representing the next game state
|
43
|
+
def self.start_game(command_line_args)
|
44
|
+
next_state = InputHandler.process_command_line_args(command_line_args)
|
45
|
+
if next_state == false
|
46
|
+
begin
|
47
|
+
next_state = DisplayController.prompt_title_menu
|
48
|
+
# If selected option has no associated game state, raise a custom error and
|
49
|
+
# re-prompt the user
|
50
|
+
raise NoFeatureError unless GameData::GAME_STATES.keys.include?(next_state)
|
51
|
+
rescue NoFeatureError => e
|
52
|
+
DisplayController.display_messages([e.message])
|
53
|
+
retry
|
54
|
+
end
|
55
|
+
else
|
56
|
+
# Console is cleared when displaying title menu. If menu is skipped with command line args, clear it here instead.
|
57
|
+
DisplayController.clear
|
58
|
+
end
|
59
|
+
return next_state
|
60
|
+
end
|
61
|
+
|
62
|
+
# Ask the player if they want to view the tutorial, and if so, display it.
|
63
|
+
# Give player the option to replay tutorial multiple times.
|
64
|
+
# Return a symbol representing the next game state (character creation).
|
65
|
+
def self.tutorial
|
66
|
+
show_tutorial = DisplayController.prompt_tutorial
|
67
|
+
while show_tutorial
|
68
|
+
DisplayController.display_messages(GameData::MESSAGES[:tutorial].call)
|
69
|
+
show_tutorial = DisplayController.prompt_tutorial(repeat: true)
|
70
|
+
end
|
71
|
+
return :character_creation
|
72
|
+
end
|
73
|
+
|
74
|
+
# Initialise Player or Map instances using given hashes of paramaters
|
75
|
+
# (or if none, default values). Return a hash containing those instances.
|
76
|
+
def self.init_player_and_map(player_data: {}, map_data: {})
|
77
|
+
player = Player.new(**player_data)
|
78
|
+
map = Map.new(player: player, **map_data)
|
79
|
+
{ player: player, map: map }
|
80
|
+
end
|
81
|
+
|
82
|
+
# Get user input to create a new character by choosing a name and
|
83
|
+
# allocating stats.
|
84
|
+
def self.character_creation
|
85
|
+
# Prompt, then reprompt unless and until save name is not already taken or user confirms overwrite
|
86
|
+
name = DisplayController.prompt_character_name
|
87
|
+
name = DisplayController.prompt_character_name until confirm_save(name)
|
88
|
+
stats = DisplayController.prompt_stat_allocation
|
89
|
+
player, map = init_player_and_map(player_data: { name: name, stats: stats }).values_at(:player, :map)
|
90
|
+
return [:world_map, [map, player]]
|
91
|
+
end
|
92
|
+
|
93
|
+
# If the user attempts to create a character with the same name as an existing
|
94
|
+
# save file, confirm whether they want to override it
|
95
|
+
def self.confirm_save(name)
|
96
|
+
path = File.join(File.dirname(__FILE__), "../saves")
|
97
|
+
if File.exist?(File.join(path, "#{name.downcase}.json"))
|
98
|
+
return DisplayController.prompt_yes_no(GameData::PROMPTS[:overwrite_save].call(name), default_no: true)
|
99
|
+
end
|
100
|
+
|
101
|
+
return true
|
102
|
+
end
|
103
|
+
|
104
|
+
# MAP MOVEMENT
|
105
|
+
|
106
|
+
# Process monster movements and render the map
|
107
|
+
def self.process_monster_movement(map, player)
|
108
|
+
tile = map.move_monsters(player.coords)
|
109
|
+
DisplayController.draw_map(map, player)
|
110
|
+
return tile
|
111
|
+
end
|
112
|
+
|
113
|
+
# Process player movement and render the map
|
114
|
+
def self.process_player_movement(map, player, key)
|
115
|
+
tile = map.process_movement(player, player.calc_destination(key.name.to_sym))
|
116
|
+
DisplayController.draw_map(map, player)
|
117
|
+
return tile
|
118
|
+
end
|
119
|
+
|
120
|
+
# Get player input and call methods to process player and monster movement on the map
|
121
|
+
def self.get_map_input(map, player)
|
122
|
+
Interaction.new.loop do |key|
|
123
|
+
prompt_quit(map, player) if GameData::EXIT_KEYS.include?(key.name.to_sym)
|
124
|
+
next unless GameData::MOVE_KEYS.keys.include?(key.name.to_sym)
|
125
|
+
|
126
|
+
tile = process_monster_movement(map, player)
|
127
|
+
return [tile.event, [player, map, tile]] unless tile.nil? || tile.event.nil?
|
128
|
+
|
129
|
+
tile = process_player_movement(map, player, key)
|
130
|
+
return [tile.event, [player, map, tile]] unless tile.nil? || tile.event.nil?
|
131
|
+
end
|
132
|
+
end
|
133
|
+
|
134
|
+
def self.prompt_quit(map, player)
|
135
|
+
DisplayController.clear
|
136
|
+
quit = DisplayController.prompt_yes_no(GameData::PROMPTS[:save_and_exit])
|
137
|
+
if quit
|
138
|
+
save_game(player, map)
|
139
|
+
exit_game
|
140
|
+
else
|
141
|
+
DisplayController.draw_map(map, player)
|
142
|
+
end
|
143
|
+
end
|
144
|
+
|
145
|
+
# Calls methods to display map, listen for user input, and update map accordingly
|
146
|
+
def self.map_loop(map, player)
|
147
|
+
# Autosave whenever entering the map
|
148
|
+
save_game(player, map)
|
149
|
+
DisplayController.set_resize_hook(map, player)
|
150
|
+
DisplayController.draw_map(map, player)
|
151
|
+
event_and_params = get_map_input(map, player)
|
152
|
+
DisplayController.cancel_resize_hook
|
153
|
+
return event_and_params
|
154
|
+
end
|
155
|
+
|
156
|
+
# COMBAT
|
157
|
+
|
158
|
+
# Get player input and process their chosen action for a single combat round.
|
159
|
+
def self.player_act(player, enemy)
|
160
|
+
begin
|
161
|
+
action = DisplayController.prompt_combat_action(player, enemy)
|
162
|
+
# Raise a custom error if selected option does not exist
|
163
|
+
raise NoFeatureError unless GameData::COMBAT_ACTIONS.keys.include?(action)
|
164
|
+
rescue NoFeatureError => e
|
165
|
+
DisplayController.display_messages([e.message])
|
166
|
+
retry
|
167
|
+
end
|
168
|
+
outcome = GameData::COMBAT_ACTIONS[action].call(player, enemy)
|
169
|
+
return { action: action, outcome: outcome }
|
170
|
+
end
|
171
|
+
|
172
|
+
# Process one round of action by an enemy in combat.
|
173
|
+
def self.enemy_act(player, enemy)
|
174
|
+
action = :enemy_attack
|
175
|
+
outcome = GameData::COMBAT_ACTIONS[action].call(player, enemy)
|
176
|
+
return { action: action, outcome: outcome }
|
177
|
+
end
|
178
|
+
|
179
|
+
# Return the outcome of a combat encounter along with parameters to pass to the
|
180
|
+
# next game state, or return false if combat has not ended
|
181
|
+
def self.check_combat_outcome(player, enemy, map, escaped: false)
|
182
|
+
return [:post_combat, [player, enemy, map, :defeat]] if player.dead?
|
183
|
+
return [:post_combat, [player, enemy, map, :victory]] if enemy.dead?
|
184
|
+
return [:post_combat, [player, enemy, map, :escaped]] if escaped
|
185
|
+
|
186
|
+
return false
|
187
|
+
end
|
188
|
+
|
189
|
+
# Level up the player, display level up message, and enter stat allocation menu
|
190
|
+
def self.level_up(player)
|
191
|
+
levels = player.level_up
|
192
|
+
DisplayController.level_up(player, levels)
|
193
|
+
player.allocate_stats(
|
194
|
+
DisplayController.prompt_stat_allocation(
|
195
|
+
starting_stats: player.stats,
|
196
|
+
starting_points: GameData::STAT_POINTS_PER_LEVEL * levels
|
197
|
+
)
|
198
|
+
)
|
199
|
+
end
|
200
|
+
|
201
|
+
# Display appropriate messages and take other required actions based on
|
202
|
+
# the outcome of a combat encounters
|
203
|
+
def self.post_combat(player, enemy, map, outcome)
|
204
|
+
enemy.heal_hp(enemy.max_hp) if outcome == :defeat
|
205
|
+
map.post_combat(player, enemy, outcome)
|
206
|
+
xp = player.post_combat(outcome, enemy)
|
207
|
+
DisplayController.post_combat(outcome, player, xp)
|
208
|
+
# If player leveled up, apply and display the level gain and prompt user to allocate stat points
|
209
|
+
level_up(player) if player.leveled_up?
|
210
|
+
DisplayController.clear
|
211
|
+
# Game state returns to the world map after combat
|
212
|
+
return [:world_map, [map, player]]
|
213
|
+
end
|
214
|
+
|
215
|
+
# Returns true if passed the return value of a player_act call where
|
216
|
+
# the player attempted to flee and succeeded
|
217
|
+
def self.fled_combat?(action_outcome)
|
218
|
+
return action_outcome == {
|
219
|
+
action: :player_flee,
|
220
|
+
outcome: true
|
221
|
+
}
|
222
|
+
end
|
223
|
+
|
224
|
+
# Process a turn of combat for the participant whose turn it is, and check if
|
225
|
+
# combat has ended, returning the outcome if so
|
226
|
+
def self.process_combat_turn(actor, player, enemy, map)
|
227
|
+
action_outcome = actor == :player ? player_act(player, enemy) : enemy_act(player, enemy)
|
228
|
+
DisplayController.clear
|
229
|
+
DisplayController.display_messages(GameData::MESSAGES[:combat_status].call(player, enemy), pause: false)
|
230
|
+
DisplayController.display_messages(GameData::MESSAGES[action_outcome[:action]].call(action_outcome[:outcome]))
|
231
|
+
return check_combat_outcome(player, enemy, map, escaped: fled_combat?(action_outcome))
|
232
|
+
end
|
233
|
+
|
234
|
+
# Manages a combat encounter by calling methods to get and process participant
|
235
|
+
# actions each round, determine when combat has ended, and return the outcome
|
236
|
+
def self.combat_loop(player, map, tile, enemy = tile.entity)
|
237
|
+
DisplayController.clear
|
238
|
+
DisplayController.display_messages(GameData::MESSAGES[:enter_combat].call(enemy))
|
239
|
+
actor = :player
|
240
|
+
loop do
|
241
|
+
combat_outcome = process_combat_turn(actor, player, enemy, map)
|
242
|
+
return combat_outcome unless combat_outcome == false
|
243
|
+
|
244
|
+
actor = actor == :enemy ? :player : :enemy
|
245
|
+
end
|
246
|
+
end
|
247
|
+
|
248
|
+
# SAVING AND LOADING
|
249
|
+
|
250
|
+
# Save all data required to re-initialise the current game state to a file
|
251
|
+
# If save fails, display a message to the user but allow program to continue
|
252
|
+
def self.save_game(player, map)
|
253
|
+
save_data = { player_data: player.export, map_data: map.export }
|
254
|
+
begin
|
255
|
+
path = File.join(File.dirname(__FILE__), "../saves")
|
256
|
+
Dir.mkdir(path) unless Dir.exist?(path)
|
257
|
+
File.write(File.join(path, "#{player.name.downcase}.json"), JSON.dump(save_data))
|
258
|
+
# If save fails, log and display the error, but let the application continue.
|
259
|
+
rescue Errno::EACCES => e
|
260
|
+
DisplayController.display_messages(GameData::MESSAGES[:general_error].call("Autosave", e, Utils.log_error(e)))
|
261
|
+
DisplayController.display_messages(GameData::MESSAGES[:save_permission_error])
|
262
|
+
rescue StandardError => e
|
263
|
+
DisplayController.display_messages(GameData::MESSAGES[:general_error].call("Autosave", e, Utils.log_error(e)))
|
264
|
+
end
|
265
|
+
end
|
266
|
+
|
267
|
+
# Prompt the user for a character name, and attempt to load a savegame file with that name
|
268
|
+
def self.load_game(character_name = nil)
|
269
|
+
begin
|
270
|
+
unless InputHandler.character_name_valid?(character_name)
|
271
|
+
character_name = DisplayController.prompt_save_name(character_name)
|
272
|
+
end
|
273
|
+
# character_name will be false if input failed validation and user chose not to retry
|
274
|
+
return :start_game if character_name == false
|
275
|
+
path = File.join(File.dirname(__FILE__), "../saves")
|
276
|
+
save_data = JSON.parse(File.read(File.join(path, "#{character_name.downcase}.json")), symbolize_names: true)
|
277
|
+
player, map = init_player_and_map(
|
278
|
+
**{ player_data: save_data[:player_data], map_data: save_data[:map_data] }
|
279
|
+
).values_at(:player, :map)
|
280
|
+
# If load fails, let user choose to retry. When they choose not to, return to title menu.
|
281
|
+
rescue Errno::ENOENT => e
|
282
|
+
DisplayController.display_messages(GameData::MESSAGES[:no_save_file_error])
|
283
|
+
return :start_game unless DisplayController.prompt_yes_no(GameData::PROMPTS[:re_load])
|
284
|
+
|
285
|
+
character_name = nil
|
286
|
+
retry
|
287
|
+
rescue Errno::EACCES => e
|
288
|
+
DisplayController.display_messages(GameData::MESSAGES[:general_error].call("Loading", e, Utils.log_error(e)))
|
289
|
+
DisplayController.display_messages(GameData::MESSAGES[:load_permission_error])
|
290
|
+
return :start_game unless DisplayController.prompt_yes_no(GameData::PROMPTS[:re_load])
|
291
|
+
|
292
|
+
character_name = nil
|
293
|
+
retry
|
294
|
+
rescue JSON::ParserError => e
|
295
|
+
DisplayController.display_messages(GameData::MESSAGES[:parse_error])
|
296
|
+
# Don't display error msg directly as it contains the full JSON file content
|
297
|
+
DisplayController.display_messages(GameData::MESSAGES[:error_hide_msg].call(Utils.log_error(e)))
|
298
|
+
return :start_game unless DisplayController.prompt_yes_no(GameData::PROMPTS[:re_load])
|
299
|
+
|
300
|
+
character_name = nil
|
301
|
+
retry
|
302
|
+
rescue StandardError => e
|
303
|
+
DisplayController.display_messages(GameData::MESSAGES[:general_error].call("Loading", e, Utils.log_error(e)))
|
304
|
+
return :start_game unless DisplayController.prompt_yes_no(GameData::PROMPTS[:re_load])
|
305
|
+
|
306
|
+
character_name = nil
|
307
|
+
retry
|
308
|
+
end
|
309
|
+
map_loop(map, player)
|
310
|
+
end
|
311
|
+
end
|
@@ -0,0 +1,331 @@
|
|
1
|
+
begin
|
2
|
+
require "colorize"
|
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
|
+
# Stores game content and parameters as constants. Keeps data separate from
|
15
|
+
# logic, so that content or parameters can be added or adjusted without changing
|
16
|
+
# the substantive code.
|
17
|
+
module GameData
|
18
|
+
# World map dimensions
|
19
|
+
MAP_WIDTH = 50
|
20
|
+
MAP_HEIGHT = 50
|
21
|
+
|
22
|
+
# Maximum map render distance (field of view)
|
23
|
+
MAX_H_VIEW_DIST = 25
|
24
|
+
MAX_V_VIEW_DIST = 25
|
25
|
+
|
26
|
+
# Constants for calculating XP to next level
|
27
|
+
LEVELING_CONSTANT = 10
|
28
|
+
LEVELING_EXPONENT = 1.6
|
29
|
+
|
30
|
+
# Stat points awarded at character creation and on level up
|
31
|
+
STAT_POINTS_PER_LEVEL = 5
|
32
|
+
|
33
|
+
# Maximum variance of monster levels from player level
|
34
|
+
MONSTER_LEVEL_VARIANCE = 1
|
35
|
+
|
36
|
+
# Multiplier for XP lost (per player level) as compared to XP gained (when defeating a monster of the same level)
|
37
|
+
XP_LOSS_MULTIPLIER = 0.5
|
38
|
+
|
39
|
+
# Default stats for any creature
|
40
|
+
DEFAULT_STATS = {
|
41
|
+
atk: {
|
42
|
+
value: 5, name: "Attack"
|
43
|
+
},
|
44
|
+
dfc: {
|
45
|
+
value: 5, name: "Defence"
|
46
|
+
},
|
47
|
+
con: {
|
48
|
+
value: 5, name: "Constitution"
|
49
|
+
}
|
50
|
+
}.freeze
|
51
|
+
|
52
|
+
# Max HP of a creature is equal to its constitution times this multiplier
|
53
|
+
CON_TO_HP = 10
|
54
|
+
|
55
|
+
# Player default starting coords
|
56
|
+
DEFAULT_COORDS = { x: 2, y: 2 }.freeze
|
57
|
+
|
58
|
+
# World map tile data
|
59
|
+
MAP_TILES = {
|
60
|
+
player: {
|
61
|
+
symbol: "@",
|
62
|
+
blocking: true,
|
63
|
+
color: :blue
|
64
|
+
},
|
65
|
+
forest: {
|
66
|
+
symbol: "T",
|
67
|
+
color: :green
|
68
|
+
},
|
69
|
+
mountain: {
|
70
|
+
symbol: "M",
|
71
|
+
color: :light_black
|
72
|
+
},
|
73
|
+
plain: {
|
74
|
+
symbol: "P",
|
75
|
+
color: :light_yellow
|
76
|
+
},
|
77
|
+
edge: {
|
78
|
+
symbol: "|",
|
79
|
+
color: :default,
|
80
|
+
blocking: true
|
81
|
+
},
|
82
|
+
monster: {
|
83
|
+
symbol: "%",
|
84
|
+
color: :red,
|
85
|
+
blocking: true,
|
86
|
+
event: :combat
|
87
|
+
}
|
88
|
+
}.freeze
|
89
|
+
|
90
|
+
TILE_DESCRIPTIONS = {
|
91
|
+
player: "You, the player.",
|
92
|
+
forest: "A forest of towering trees.",
|
93
|
+
mountain: "Rugged, mountainous terrain.",
|
94
|
+
plain: "Vast, empty plains.",
|
95
|
+
edge: "An impassable wall.",
|
96
|
+
monster: "A terrifying monster. You should fight it!"
|
97
|
+
}.freeze
|
98
|
+
|
99
|
+
MAP_HEADER = lambda { |player|
|
100
|
+
[
|
101
|
+
player.name.upcase.colorize(:light_yellow),
|
102
|
+
"HEALTH: ".colorize(:light_white) +
|
103
|
+
"#{player.current_hp}/#{player.max_hp}".colorize(player.health_color),
|
104
|
+
"ATK: ".colorize(:light_white) +
|
105
|
+
player.stats[:atk][:value].to_s.colorize(:green) +
|
106
|
+
" DEF: ".colorize(:light_white) +
|
107
|
+
player.stats[:dfc][:value].to_s.colorize(:green) +
|
108
|
+
" CON: ".colorize(:light_white) +
|
109
|
+
player.stats[:con][:value].to_s.colorize(:green),
|
110
|
+
"LEVEL: ".colorize(:light_white) +
|
111
|
+
player.level.to_s.colorize(:green) +
|
112
|
+
" XP: ".colorize(:light_white) +
|
113
|
+
player.xp_progress.to_s.colorize(:green),
|
114
|
+
" "
|
115
|
+
]
|
116
|
+
}
|
117
|
+
|
118
|
+
# Keypress inputs for movement, and their associated coord changes
|
119
|
+
MOVE_KEYS = {
|
120
|
+
left: { x: -1, y: 0 },
|
121
|
+
a: { x: -1, y: 0 },
|
122
|
+
right: { x: 1, y: 0 },
|
123
|
+
d: { x: 1, y: 0 },
|
124
|
+
up: { x: 0, y: -1 },
|
125
|
+
w: { x: 0, y: -1 },
|
126
|
+
down: { x: 0, y: 1 },
|
127
|
+
s: { x: 0, y: 1 },
|
128
|
+
}.freeze
|
129
|
+
|
130
|
+
# Keypress inputs that will attempt to save and then exit the game from the map screen
|
131
|
+
# control_left_square_bracket is equivalent to the escape key
|
132
|
+
EXIT_KEYS = [:q, :control_left_square_bracket].freeze
|
133
|
+
|
134
|
+
GAME_STATES = {
|
135
|
+
start_game: ->(game_controller, _params) { game_controller.start_game([]) },
|
136
|
+
new_game: ->(game_controller, _params) { game_controller.tutorial },
|
137
|
+
load_game: ->(game_controller, params) { game_controller.load_game(params) },
|
138
|
+
exit_game: ->(game_controller, _params) { game_controller.exit_game },
|
139
|
+
character_creation: ->(game_controller, _params) { game_controller.character_creation },
|
140
|
+
world_map: ->(game_controller, params) { game_controller.map_loop(*params) },
|
141
|
+
combat: ->(game_controller, params) { game_controller.combat_loop(*params) },
|
142
|
+
post_combat: ->(game_controller, params) { game_controller.post_combat(*params) }
|
143
|
+
}.freeze
|
144
|
+
|
145
|
+
# Actions that may be taken in combat, and their associated callbacks
|
146
|
+
COMBAT_ACTIONS = {
|
147
|
+
player_attack: ->(player, enemy) { enemy.receive_damage(player.calc_damage_dealt) },
|
148
|
+
|
149
|
+
player_flee: ->(player, enemy) { player.flee(enemy) },
|
150
|
+
|
151
|
+
enemy_attack: ->(player, enemy) { player.receive_damage(enemy.calc_damage_dealt) }
|
152
|
+
}.freeze
|
153
|
+
|
154
|
+
# Title menu options and their return values
|
155
|
+
# Strings used as keys to match tty-prompt requirements
|
156
|
+
TITLE_MENU_OPTIONS = {
|
157
|
+
"New Game" => :new_game,
|
158
|
+
"Load Game" => :load_game,
|
159
|
+
"Exit" => :exit_game
|
160
|
+
}.freeze
|
161
|
+
|
162
|
+
# Combat menu options and their return values
|
163
|
+
COMBAT_MENU_OPTIONS = {
|
164
|
+
"Attack" => :player_attack,
|
165
|
+
"Flee" => :player_flee
|
166
|
+
}.freeze
|
167
|
+
|
168
|
+
COMMAND_LINE_ARGUMENTS = {
|
169
|
+
new_game: ["-n", "--new"],
|
170
|
+
load_game: ["-l", "--load"]
|
171
|
+
}.freeze
|
172
|
+
|
173
|
+
# Validation requirements to display to the user
|
174
|
+
VALIDATION_REQUIREMENTS = {
|
175
|
+
character_name: "Names must contain only letters, numbers and underscores, be 3 to 15 characters in length"\
|
176
|
+
", and not contain spaces."
|
177
|
+
}.freeze
|
178
|
+
|
179
|
+
PROMPTS = {
|
180
|
+
re_load: "Would you like to try loading again?",
|
181
|
+
overwrite_save: lambda { |name|
|
182
|
+
"It looks like a saved game already exists for that character name. Are you "\
|
183
|
+
"sure you want to overwrite the save file for #{name.upcase.colorize(:light_yellow)}?" },
|
184
|
+
save_and_exit: "Would you like to exit the game? Your progress will be saved."
|
185
|
+
}.freeze
|
186
|
+
|
187
|
+
ASCII_ART = {
|
188
|
+
title: lambda { |font, console_size|
|
189
|
+
# Pad based on console and text size. Ascii text is centered horizontally,
|
190
|
+
# and about ~1/5 of the way down the screen vertically (leaving room for content below).
|
191
|
+
top_pad = [(console_size.rows / 5), 0].max
|
192
|
+
left_pad = [(console_size.cols / 2) - 22, 0].max
|
193
|
+
return "#{"\n"*top_pad}#{font.write('Terminal'.rjust(left_pad))}\n"\
|
194
|
+
"#{font.write('Hero'.rjust(10 + left_pad))}\n".colorize(:light_yellow)
|
195
|
+
},
|
196
|
+
level_up: lambda { |font, console_size|
|
197
|
+
top_pad = [(console_size.rows / 5), 0].max
|
198
|
+
left_pad = [(console_size.cols / 2) - 12, 0].max
|
199
|
+
"#{"\n"*top_pad}#{font.write('Level'.rjust(left_pad))}\n#{font.write('Up'.rjust(8 + left_pad))}\n".colorize(:light_yellow)
|
200
|
+
}
|
201
|
+
}
|
202
|
+
|
203
|
+
# Arrays of strings to be displayed in turn by the display controller
|
204
|
+
# (or callbacks generating such arrays)
|
205
|
+
MESSAGES = {
|
206
|
+
not_implemented: ["Sorry, it looks like you're trying to access a feature that hasn't been implemented yet."\
|
207
|
+
"Try choosing something else!"],
|
208
|
+
|
209
|
+
tutorial: lambda {
|
210
|
+
msgs = []
|
211
|
+
msgs.push "Welcome to Terminal Hero! In a moment, you will be prompted to create a character, but first,"\
|
212
|
+
"let's go over how things work."
|
213
|
+
msgs.push "When you enter the game, you will be presented with a map made up of the following symbols:"
|
214
|
+
MAP_TILES.each do |key, tile|
|
215
|
+
msgs.push " #{tile[:symbol]} : #{TILE_DESCRIPTIONS[key]}".colorize(tile[:color])
|
216
|
+
end
|
217
|
+
msgs.push "You can move your character around the map using the arrow keys or \"WASD\" controls."
|
218
|
+
msgs.push "It's a good idea to expand your terminal to full-screen, so that you can see further on the map."
|
219
|
+
msgs.push "If you run into a monster, you will enter combat."
|
220
|
+
msgs.push "In combat, you and the enemy will take turns to act."\
|
221
|
+
"You will select your action each round from a list of options."
|
222
|
+
msgs.push "Combat continues until you or the enemy loses all their hit points (HP), or you flee the battle."
|
223
|
+
msgs.push "When you defeat an enemy, you will gain experience points (XP). When you lose, you will lose some XP"\
|
224
|
+
"(but you won't lose levels). You will then be revived with full HP."
|
225
|
+
msgs.push "When you gain enough XP, you will level up."
|
226
|
+
msgs.push "Leveling up awards stat points, which you can expend to increase your combat statistics. These are:"
|
227
|
+
msgs.push "#{'Attack'.colorize(:red)}: With higher attack, you will deal more damage in combat."
|
228
|
+
msgs.push "#{'Defence'.colorize(:blue)}: With higher defence, you will receive less damage in combat."
|
229
|
+
msgs.push "#{'Constitution'.colorize(:green)}: Determines your maximum HP."
|
230
|
+
msgs.push "You can see your current level, HP and stats above the map at any time."
|
231
|
+
msgs.push "The game will automatically save after every battle."
|
232
|
+
msgs.push "To load a saved game, select \"load\" from the title menu and enter the name of a character with "\
|
233
|
+
"an existing save file when prompted."
|
234
|
+
msgs.push "Alternatively, you can pass the arguments \"-l\" or \"--load\" followed by a saved character's name"\
|
235
|
+
"when running the game from the command line."
|
236
|
+
msgs.push "You can also pass the arguments \"-n\" or \"--new\" to skip the title menu and "\
|
237
|
+
"jump straight into a new game."
|
238
|
+
msgs.push "You can press \"escape\" or \"q\" on the map screen at any time to save and exit the game."
|
239
|
+
msgs.push "That's all there is to it. Have fun!"
|
240
|
+
return msgs
|
241
|
+
},
|
242
|
+
|
243
|
+
enter_combat: ->(enemy) { ["You encountered a level #{enemy.level} #{enemy.name}!"] },
|
244
|
+
|
245
|
+
combat_status: lambda { |player, enemy|
|
246
|
+
[
|
247
|
+
"Your health: #{"#{player.current_hp} / #{player.max_hp}".colorize(player.health_color)} | "\
|
248
|
+
"Enemy health: #{"#{enemy.current_hp} / #{enemy.max_hp}".colorize(enemy.health_color)}"
|
249
|
+
]
|
250
|
+
},
|
251
|
+
|
252
|
+
player_attack: lambda { |damage|
|
253
|
+
["You attacked the enemy, dealing #{damage} damage!\n"]
|
254
|
+
},
|
255
|
+
|
256
|
+
enemy_attack: lambda { |damage|
|
257
|
+
["The enemy attacked you, dealing #{damage} damage!\n"]
|
258
|
+
},
|
259
|
+
|
260
|
+
player_flee: lambda { |success|
|
261
|
+
msgs = ["You attempt to flee..."]
|
262
|
+
msgs.push("You couldn't get away!") unless success
|
263
|
+
return msgs
|
264
|
+
},
|
265
|
+
|
266
|
+
combat_victory: lambda { |xp|
|
267
|
+
msgs = ["You defeated the enemy!"]
|
268
|
+
msgs.push xp == 1 ? "You received #{xp} experience point!" : "You received #{xp} experience points!"
|
269
|
+
return msgs
|
270
|
+
},
|
271
|
+
|
272
|
+
leveled_up: lambda { |player, levels|
|
273
|
+
msgs = levels == 1 ? ["You gained #{levels} level!"] : ["You gained #{levels} levels!"]
|
274
|
+
msgs[0] += " You are now level #{player.level}!"
|
275
|
+
return msgs
|
276
|
+
},
|
277
|
+
|
278
|
+
level_progress: ->(player) { ["XP to next level: #{player.xp_progress}"] },
|
279
|
+
|
280
|
+
combat_defeat: ->(xp) { ["You were defeated! You lost #{xp} XP."] },
|
281
|
+
|
282
|
+
combat_escaped: ["You got away!"],
|
283
|
+
|
284
|
+
exit_game: ["Thanks for playing! See you next time."],
|
285
|
+
|
286
|
+
general_error: ->(action, e, file_path, msg: "#{action} failed: an error occurred.") {
|
287
|
+
[
|
288
|
+
msg.colorize(:red),
|
289
|
+
" \"#{e.message}.\"".colorize(:yellow),
|
290
|
+
"Details of the error have been logged to \"#{file_path.colorize(:light_blue)}.\" "\
|
291
|
+
"If you would like to submit a bug report, please include a copy of this file."
|
292
|
+
]
|
293
|
+
},
|
294
|
+
|
295
|
+
out_of_bounds_error: "You have attempted to move outside the game map. If you loaded the game,"\
|
296
|
+
"your save file may have been modified or corrupted.".colorize(:red),
|
297
|
+
|
298
|
+
error_hide_msg: ->(file_path) {
|
299
|
+
[
|
300
|
+
"Details of the error have been logged to \"#{file_path.colorize(:light_blue)}.\" "\
|
301
|
+
"If you would like to submit a bug report, please include a copy of this file."
|
302
|
+
]
|
303
|
+
},
|
304
|
+
|
305
|
+
save_permission_error: [
|
306
|
+
"To enable saving, please ensure that the current user has "\
|
307
|
+
"write access to the directory where the game has been installed"\
|
308
|
+
"and to files in the \"termina_hero/saves\" subfolder."
|
309
|
+
],
|
310
|
+
|
311
|
+
no_save_file_error: [
|
312
|
+
"No save was file found for that character. Input "\
|
313
|
+
"must match the character's name exactly (but is not case sensitive).".colorize(:red)
|
314
|
+
],
|
315
|
+
|
316
|
+
load_permission_error: [
|
317
|
+
"To enable loading, please ensure that the current user has "\
|
318
|
+
"read access to files in the \"terminal_hero/saves\" subfolder."
|
319
|
+
],
|
320
|
+
|
321
|
+
parse_error: [
|
322
|
+
"The save file you are trying to load could not be parsed. It may have been modified or corrupted.".colorize(:red)
|
323
|
+
],
|
324
|
+
|
325
|
+
dependency_load_error: [
|
326
|
+
"It appears that a dependency was not able to be loaded.",
|
327
|
+
"Please try installing dependencies mannually by running the command \"bundle install\" from within the installation directory.",
|
328
|
+
"If you installed this application as a gem, you could try reinstalling it by running \"gem uninstall terminal_hero\" followed by \"gem install terminal_hero\""
|
329
|
+
]
|
330
|
+
}.freeze
|
331
|
+
end
|