├── .gitignore ├── LICENSE.txt ├── README.md ├── Rakefile ├── TODO ├── bin └── game_player.rb ├── data └── epic_adventure │ ├── items.yml │ ├── locations.yml │ └── messages.yml ├── lib ├── avatar.rb ├── bootstrap.rb ├── engine.rb ├── game.rb ├── game_data_loader.rb ├── input_controller.rb └── room.rb ├── play.sh └── tests ├── avatar_test.rb └── room_test.rb /.gitignore: -------------------------------------------------------------------------------- 1 | .swp 2 | .DS_Store 3 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Text Adventure - a simple text game 2 | 3 | Copyright (C) 2015 Sebastian Wittenkamp 4 | 5 | This program is free software: you can redistribute it and/or modify 6 | it under the terms of the GNU General Public License as published by 7 | the Free Software Foundation, either version 3 of the License, or 8 | (at your option) any later version. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | long with this program. If not, see . 17 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Text Adventures! 2 | 3 | ## About 4 | 5 | This is a text game and engine that I am writing just for fun. And to learn more about the basics of game programming. I loved 6 | playing games like Kings Quest when I was a kid and text adventures harken back to those days for me. 7 | 8 | Text games also remind me of a book I borrowed when I was a kid. It was a thick book full of BASIC code that you would type 9 | in to the computer and then run it. When you ran the code, you had a full game! That was REAL open source because you had 10 | to make the effort to type all 25 or 50 pages of code in, while the editor was hot. Makes you grateful for how far 11 | we've come since then. :) 12 | 13 | I'm going to continue updating the data files for the "Epic Adventure" so that the story gets bigger and bigger. But if 14 | you play the game, happen to like it, and have an idea, please feel free to contribute it. 15 | 16 | This project is all about fun so I'm always open to new ideas. 17 | 18 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'rake/testtask' 2 | 3 | Rake::TestTask.new do |t| 4 | t.pattern = "tests/*_test.rb" 5 | end 6 | 7 | task :default => [:test] 8 | -------------------------------------------------------------------------------- /TODO: -------------------------------------------------------------------------------- 1 | * add an inventory in the game. 2 | * add a real grammar for parsing commands 3 | * state machine for NPC interaction 4 | * level editor 5 | -------------------------------------------------------------------------------- /bin/game_player.rb: -------------------------------------------------------------------------------- 1 | GAME_ROOT = File.expand_path(File.join(File.dirname(__FILE__), "..")) 2 | 3 | require File.join(GAME_ROOT, 'lib', 'bootstrap') 4 | require File.join(GAME_ROOT, 'lib', 'game') 5 | 6 | def lookup_file_from(path) 7 | File.absolute_path(File.join(GAME_ROOT, path)) 8 | end 9 | 10 | # TODO: this could be a little nicer. 11 | location_data_file = lookup_file_from "#{ARGV[0]}" 12 | message_data_file = lookup_file_from "#{ARGV[1]}" 13 | 14 | # main 15 | bootstrap = Bootstrap.new(location_data_file, message_data_file) 16 | game = Game.new(bootstrap) 17 | game.play -------------------------------------------------------------------------------- /data/epic_adventure/items.yml: -------------------------------------------------------------------------------- 1 | - 2 | handle: crystal 3 | name: Glowing Crystal 4 | description: This crystal glows with an eerie light. 5 | - 6 | -------------------------------------------------------------------------------- /data/epic_adventure/locations.yml: -------------------------------------------------------------------------------- 1 | - 2 | starting_location: true 3 | handle: lake 4 | desc: A beautiful lake surrounded by trees. 5 | info: A trail runs to the north from here. 6 | rooms: 7 | north: forest 8 | - 9 | handle: forest 10 | desc: A forest full of lush trees. 11 | info: The path splits. To the west lies your home. To the east are the mountains. Back to the south is a lake. 12 | rooms: 13 | west: home 14 | east: mountains 15 | south: lake 16 | - 17 | handle: home 18 | desc: Your home. It is a happy house. 19 | info: Your house is here. It is a quiet place. To the east is the path in the forest. If you go west you will enter your house. 20 | rooms: 21 | east: forest 22 | west: home_main 23 | - 24 | handle: home_main 25 | desc: You are standing in the main room of your house. 26 | info: The inside of your house is simple but clean and quiet. You have spent many happy hours here. The study is in the north room. 27 | rooms: 28 | north: home_study 29 | east: home 30 | - 31 | handle: home_study 32 | desc: You are in your study. It is a clean room with a desk and windows facing outside. 33 | info: Your study is where your work gets done. There is a letter on your desk. 34 | rooms: 35 | south: home_main 36 | - 37 | handle: mountains 38 | desc: These are the mountains. From up here you can see the valley in which you live. 39 | info: Further to the east is the pass that leads to the ocean. Back west is the forest. 40 | rooms: 41 | west: forest 42 | east: pass 43 | - 44 | handle: pass 45 | desc: This is the pass that leads to the ocean. It is rocky terrain with huge stone walls all around. 46 | info: To the east is the ocean. Back west lies the mountains. You can smell the ocean. 47 | rooms: 48 | west: mountains 49 | east: ocean 50 | - 51 | handle: ocean 52 | desc: You are standing by the ocean. It is overcast and cloudy here. Seagulls circle overhead. 53 | info: Further east along the beach you can see some buildings. Back to the west lies the pass. 54 | rooms: 55 | west: pass 56 | east: village 57 | - 58 | handle: village 59 | desc: You are standing among the buildings of an abandoned village. 60 | info: There is a strange glowing crystal lying on the ground. 61 | items: crystal 62 | rooms: 63 | west: ocean 64 | 65 | -------------------------------------------------------------------------------- /data/epic_adventure/messages.yml: -------------------------------------------------------------------------------- 1 | splash: An Epic Adventure awaits you. Type 'help' for instructions. 2 | help: Type 'look' for more information. You can 'go ' to move around. Type 'exit' or 'quit' to stop the game. 3 | -------------------------------------------------------------------------------- /lib/avatar.rb: -------------------------------------------------------------------------------- 1 | class Avatar 2 | 3 | def location 4 | @current_room 5 | end 6 | 7 | def initialize(starting_location) 8 | @current_room = starting_location 9 | end 10 | 11 | def can_move?(direction) 12 | @current_room.has_room_to_the?(direction) 13 | end 14 | 15 | def move(direction) 16 | if can_move?(direction) 17 | new_room = @current_room.rooms[direction] 18 | @current_room = new_room 19 | true 20 | else 21 | false 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/bootstrap.rb: -------------------------------------------------------------------------------- 1 | lib_dir = File.expand_path(File.dirname(__FILE__)) 2 | 3 | require File.join(lib_dir, 'avatar') 4 | require File.join(lib_dir, 'input_controller') 5 | require File.join(lib_dir, 'game_data_loader') 6 | 7 | class Bootstrap 8 | def initialize(location_data_file, message_data_file) 9 | @locations = loader.load_location_data(location_data_file) 10 | @messages = loader.load_message_data(message_data_file) 11 | end 12 | 13 | def starting_location 14 | @locations.find {|location| location.starting_location?} 15 | end 16 | 17 | def avatar 18 | Avatar.new(starting_location) 19 | end 20 | 21 | def loader 22 | @loader ||= GameDataLoader.new 23 | end 24 | 25 | def controller 26 | ctl = InputController.new 27 | ctl.messages = @messages 28 | ctl.avatar = avatar 29 | ctl.initialize_message 30 | ctl 31 | end 32 | 33 | def splash_message 34 | @messages["splash"] 35 | end 36 | end -------------------------------------------------------------------------------- /lib/engine.rb: -------------------------------------------------------------------------------- 1 | require 'readline' 2 | 3 | class Engine 4 | attr_accessor :splash_message 5 | 6 | def initialize(controller) 7 | @ctl = controller 8 | end 9 | 10 | def repl 11 | puts @ctl.current_message 12 | puts 13 | input = read_line 14 | @ctl.evaluate(input) 15 | repl 16 | end 17 | 18 | def read_line 19 | Readline.readline('> ', true) 20 | end 21 | 22 | def start 23 | # Print splash message 24 | puts @splash_message 25 | # Start the game loop 26 | repl 27 | end 28 | 29 | end -------------------------------------------------------------------------------- /lib/game.rb: -------------------------------------------------------------------------------- 1 | require File.join(File.expand_path(File.dirname(__FILE__)), 'engine') 2 | 3 | class Game 4 | def initialize(bootstrap) 5 | @engine = engine.new(bootstrap.controller) 6 | @engine.splash_message = bootstrap.splash_message 7 | end 8 | 9 | def play 10 | @engine.start 11 | end 12 | 13 | def engine 14 | Engine 15 | end 16 | end -------------------------------------------------------------------------------- /lib/game_data_loader.rb: -------------------------------------------------------------------------------- 1 | require File.join(File.dirname(__FILE__), 'room') 2 | require 'yaml' 3 | 4 | class GameDataLoader 5 | 6 | def load_location_data(file) 7 | data = load_data_from(file) 8 | rooms = load_initial_state(data) 9 | establish_relationships(rooms) 10 | rooms 11 | end 12 | 13 | def load_message_data(file) 14 | load_data_from(file) 15 | end 16 | 17 | def load_initial_state(data) 18 | rooms = [] 19 | data.each {|room_data| rooms << build_room(room_data)} 20 | rooms 21 | end 22 | 23 | def establish_relationships(all_rooms) 24 | all_rooms.each do |room| 25 | room.rooms.each do |direction, handle| 26 | room.rooms[direction] = all_rooms.find {|r| r.handle == handle} 27 | end 28 | end 29 | end 30 | 31 | def build_room(room_data) 32 | room = get_room 33 | room.handle = room_data["handle"] 34 | room.description = room_data["desc"] 35 | room.info = room_data["info"] 36 | room.rooms = room_data["rooms"] 37 | room.starting_location = room_data["starting_location"] 38 | room 39 | end 40 | 41 | private 42 | def get_room 43 | Room.new 44 | end 45 | 46 | def load_data_from(file) 47 | YAML.load_file(file) 48 | end 49 | 50 | end 51 | -------------------------------------------------------------------------------- /lib/input_controller.rb: -------------------------------------------------------------------------------- 1 | class InputController 2 | attr_reader :avatar, :current_message 3 | 4 | def avatar=(avatar) 5 | @avatar = avatar 6 | end 7 | 8 | def messages=(messages) 9 | @messages = messages 10 | end 11 | 12 | def initialize_message 13 | @current_message = avatar.location.description 14 | end 15 | 16 | def evaluate(input) 17 | tokens = input.split 18 | unless valid?(input) 19 | @current_message = "Sorry, that is not a valid command." 20 | return 21 | end 22 | 23 | command = tokens.first 24 | 25 | if command == "go" 26 | direction = tokens.last 27 | if avatar.can_move?(direction) 28 | avatar.move(direction) 29 | @current_message = avatar.location.description 30 | else 31 | @current_message = "Sorry, you cannot go #{direction} from here." 32 | end 33 | end 34 | 35 | if command == "look" 36 | @current_message = avatar.location.info 37 | end 38 | 39 | if command == "help" 40 | @current_message = @messages["help"] 41 | end 42 | 43 | if command == "exit" || command == "quit" 44 | puts "Thank you for playing!" 45 | exit(0) 46 | end 47 | end 48 | 49 | def valid?(input) 50 | tokens = input.split 51 | result = false 52 | if valid_commands.include?(tokens.first) && tokens.size == 1 53 | result = true 54 | elsif tokens.size == 2 55 | result = true 56 | end 57 | result 58 | end 59 | 60 | def valid_commands 61 | @commands ||= %w(look exit quit help) 62 | end 63 | 64 | end 65 | -------------------------------------------------------------------------------- /lib/room.rb: -------------------------------------------------------------------------------- 1 | class Room 2 | attr_accessor :description, :rooms, :items, :info, :handle 3 | attr_writer :starting_location 4 | 5 | def has_room_to_the?(direction) 6 | rooms.key?(direction) 7 | end 8 | 9 | def starting_location? 10 | @starting_location 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /play.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | ruby bin/game_player.rb data/epic_adventure/locations.yml data/epic_adventure/messages.yml 3 | -------------------------------------------------------------------------------- /tests/avatar_test.rb: -------------------------------------------------------------------------------- 1 | require 'minitest/autorun' 2 | require File.join(File.expand_path(File.dirname(__FILE__)), '..', 'lib', 'avatar') 3 | 4 | class AvatarTest < MiniTest::Unit::TestCase 5 | 6 | def setup 7 | @starting_location = MiniTest::Mock.new 8 | @avatar = Avatar.new(@starting_location) 9 | end 10 | 11 | def teardown 12 | @starting_location.verify 13 | end 14 | 15 | def test_avatar_has_location 16 | assert_equal @starting_location, @avatar.location 17 | end 18 | 19 | def test_avatar_knows_if_can_move_in_a_direction 20 | @starting_location.expect(:has_room_to_the?, true, ["north"]) 21 | assert @avatar.can_move?("north") 22 | 23 | @starting_location.expect(:has_room_to_the?, false, ["east"]) 24 | refute @avatar.can_move?("east") 25 | end 26 | 27 | def test_avatar_can_move 28 | @starting_location.expect(:has_room_to_the?, true, ["east"]) 29 | rooms = MiniTest::Mock.new 30 | room = MiniTest::Mock.new 31 | rooms.expect(:[], room, ["east"]) 32 | @starting_location.expect(:rooms, rooms) 33 | assert @avatar.move("east") 34 | assert_equal room, @avatar.location 35 | end 36 | 37 | end 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /tests/room_test.rb: -------------------------------------------------------------------------------- 1 | require 'minitest/autorun' 2 | require File.join(File.expand_path(File.dirname(__FILE__)), '..', 'lib', 'room') 3 | 4 | class RoomTest < MiniTest::Unit::TestCase 5 | 6 | def setup 7 | @room = Room.new 8 | @room.description = "A room." 9 | @room.rooms = [] 10 | @room.items = [] 11 | @room.info = "Info about the room." 12 | @room.handle = "Test room" 13 | end 14 | 15 | def test_room_has_properties 16 | assert_equal("A room.", @room.description) 17 | assert_equal([], @room.rooms) 18 | assert_equal([], @room.items) 19 | assert_equal("Info about the room.", @room.info) 20 | assert_equal("Test room", @room.handle) 21 | end 22 | 23 | def test_room_knows_if_it_is_starting_location 24 | @room.starting_location = false 25 | refute(@room.starting_location?) 26 | @room.starting_location = true 27 | assert(@room.starting_location?) 28 | end 29 | 30 | def test_rooms_knows_adjacent_rooms 31 | @room.rooms = { 32 | "north" => "room_a", 33 | "south" => "room_b" 34 | } 35 | assert(@room.has_room_to_the?("north")) 36 | assert(@room.has_room_to_the?("south")) 37 | refute(@room.has_room_to_the?("west")) 38 | refute(@room.has_room_to_the?("east")) 39 | end 40 | 41 | end 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | --------------------------------------------------------------------------------