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