├── .gitignore ├── LICENSE ├── README.md ├── bower.json ├── examples ├── entities.js ├── items.js ├── screens.js └── tiles.js ├── hobgoblin.js ├── package-lock.json ├── package.json ├── src ├── ai-tasks.js ├── ai.js ├── commands.js ├── entity-mixins.js ├── entity.js ├── game.js ├── geometry.js ├── glyph-dynamic.js ├── glyph.js ├── input.js ├── item-mixins.js ├── item.js ├── loader.js ├── map.js ├── palette.js ├── repository.js ├── screen.js ├── tile.js ├── tiles.js └── utilities.js └── windows_test.bat /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | bower_components/ 3 | node_modules/ 4 | test/ 5 | *.sublime* 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Jake Franklin 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # HobgoblinJS 2 | 3 | A JavaScript framework that implements ROT.js for building Roguelike games in the browser. The architecture is heavily inspired from the ways that [Ondras](http://ondras.zarovi.cz/) (creator of ROT.js) organizes many of his games, and the tutorial series by [Dominic Charley-Roy](http://www.codingcookies.com/2013/04/01/building-a-roguelike-in-javascript-part-1/), [Steve Losh](http://stevelosh.com/blog/2012/07/caves-of-clojure-01/), and [Trystan](http://trystans.blogspot.com/2016/01/roguelike-tutorial-00-table-of-contents.html), who have written the same tutorial in JavaScript, Clojure, and Java, respectively. 4 | 5 | ## Installation 6 | 7 | `npm install -g hobgoblin` to have access to the tool globally 8 | 9 | OR 10 | 11 | `npm install --save-dev hobgoblin` 12 | 13 | ## Usage 14 | 15 | `hobgoblin init` (or `node node_modules/hobgoblin/hobgoblin.js` if you installed locally) from the root of your project will initialize the framework, and you should end up with the following directory structure: 16 | 17 | ``` 18 | js/ 19 | game.js 20 | utilities.js 21 | palette.js 22 | geometry.js 23 | repository.js 24 | glyph.js 25 | glyph-dynamic.js 26 | tile.js 27 | tiles.js 28 | entity.js 29 | entity-mixins.js 30 | item.js 31 | item-mixins.js 32 | map.js 33 | screen.js 34 | ai.js 35 | ai-tasks.js 36 | index.html 37 | ``` 38 | 39 | `hobgoblin init --examples` will pull down example implementations of entities, tiles, screens, and items. In fact, initializing hobgoblin with examples will start you out with a fully playable (albeit boring) roguelike: 40 | 41 | ``` 42 | js/ 43 | ... 44 | example-screens.js 45 | example-tiles.js 46 | example-items.js 47 | example-entities.js 48 | ``` 49 | 50 | Hack away! You could start by simply adding items and monsters. I find that I like to start with `entity.js` to define what the basic actor in my game will look like and adjusting the `entity-mixins.js` accordingly. 51 | 52 | Hobgoblin is supposed to be a starting point; it's not like a traditional framework that you would use more or less as-is. Since game mechanics go all the way down to the core, *these files are all meant to be edited as needed*. Some files will be touched less than others, but some files will need to be updated heavily for every game. 53 | 54 | ### Important! 55 | 56 | Hobgoblin does not currently install ROT.js for you, but it is required for the framework to run. You can get it from [Ondras's repo directly](http://ondras.github.io/rot.js/hp/) or via Bower: `bower install --save rot.js`. 57 | 58 | If using examples, you MUST comment out `example-tiles.js` in `index.html` or else it will overwrite the existing `Game.TileRepository` and break. Besides this however, when using examples, a fully explorable, multi-tiered dungeon will be available upon browsing to `index.html`. 59 | 60 | Without examples, you will need to implement at least one screen and update `game.js` to reference this screen instead of `Game.Screen.startScreen`. This will include things like handling input, but you should be able to leverage the generic implementation of `map.js` to create levels very easily. I would highly recommend using `hobgoblin init --examples` in order to see one way this can be done. 61 | 62 | ## Hobgoblin Framework 63 | 64 | The framework is organized as follows: `Game` is the namespace of the game. The naming convention I use is usually this: 65 | 66 | 1. `Game.Foo` is found in `foo.js` 67 | 2. The namespace for `foos.js` (plural of 'foo') will be either `Game.FooRepository` or `Game.Foos` (usually the former) 68 | 3. `Game.FooBar` is found in `foo-bar.js` 69 | 70 | `Game` is meant to store global settings. It also contains the logic for initializing the game, and displaying the starting screen. 71 | 72 | `Game.Screen` is where screens are defined. Screens are containers for most of the actual game play. For instance, in the examples, `Game.Screen.playScreen` is where the player is initialized, as well as the game map. Screens each contain logic for rendering and handling input. 73 | 74 | `Game.Map` houses the current world. It is where monsters, items and tiles are generated. 75 | 76 | Entities, items, and tiles are generated via repositories. Repositories are holders for templates, as well as generators for specific objects. For example, `Game.EntityRepository` holds the templates for entities. Calling `Game.EntityRepository.create('templateName')`, will return a new entity object using the designated constructor, in this case `Game.Entity`. 77 | 78 | Entities and items are instances of `Game.DynamicGlyph`, and can use Mixins and Listeners. 79 | 80 | `Game.AI` and `Game.AI.Tasks` are where the logic for, you guessed it, AI is found. More on this below. 81 | 82 | `Game.Palette`, `Game.Geometry`, and `Game.Utility` are helper containers. 83 | 84 | ### `Game` 85 | 86 | This namespace holds global settings, such as screen size, information on ROT.js displays, and game mechanic settings. For instance, I like to define things like 'numMonstersPerLevel' and things like that at this level, with the appropriate getter functions. 87 | 88 | It also contains the logic for what to do on window load: 89 | 90 | 1. Initialize the game: This instantiates the ROT.js displays, and attaches event listeners to the window. 91 | 2. Switch the screen to the start screen 92 | 93 | Lastly, `Game` contains the logic for rendering its current screen (`Game.refresh()`). 94 | 95 | ### `Game.Screen` 96 | 97 | Screens are created via prototypes. `screen.js` contains these prototypes, and `example-screens.js` contains example implementations of these. 98 | 99 | In the examples, as well as my own games, `Game.Screen.playScreen` is the most important screen, because it initializes the player, as well as the map, and handles the controls for actual game play. `Game.Screen.playScreen` also has sub-screen functionality, allowing things like menus to be setup and torn down easily by passing in the player information. 100 | 101 | `Game.Screen.playScreen` does one other important thing after initializing the map: *it starts the map's engine.* 102 | 103 | Screens should contain the logic for rendering themselves, as well as their own controls. 104 | 105 | Whenever `Game.refresh()` is called, the ROT.js display is cleared, and then the `render` function is called for the current screen. Input is likewise routed to the Game's current screen. 106 | 107 | ### `Game.Map` 108 | 109 | Whenever `new Game.Map(player)` is called, it 110 | 111 | 1. Generates the tiles (world) 112 | 2. Sets up FOV using these tiles via ROT.js 113 | 3. Creates a scheduler and engine via ROT.js 114 | 4. Generates and distributes items 115 | 5. Generates and distributes monsters. 116 | 6. Sets up an empty list of explored tiles (to create a fog of war effect) 117 | 118 | The things I touch the most in this file are the tile generator, the entity generator, and the item generator. Often, I will abstract out the tile generation functionalities to another module, as this can get quite large and complicated very quickly for larger games. 119 | 120 | The map is what handles the addition of and the removal of entities, since the functions used to do this also abstract out the process of adding and removing them to the scheduler. 121 | 122 | Note: the engine is actually started by `Game.screen.playScreen.enter()`. 123 | 124 | ### `Game.Glyph` and `Game.DynamicGlyph` -- Tiles, Entities, and Items 125 | 126 | `Game.Glyph` is the most basic unit in Hobgoblin. It consists of a name, a character, colors, and a little bit of logic for how to display it. 127 | 128 | `Game.Tile` uses `Game.Glyph` as its constructor, and implements some basic logic about whether or not a tile can be seen through or walked upon. 129 | 130 | `Game.DynamicGlyph` extends the basic glyph structure to include more helper functions for describing them on screen, and a couple of very useful features: Mixins, and Listeners. 131 | 132 | Mixins can be thought of as optional modules. When a dynamic glyph template is defined, it can specify an array of mixins to include with it. When that template is instantiated via its repository's `create()` method, all of the mixin properties get added to the object, as well as any additional functionality. For instance, the `Game.EntityMixin.FoodConsumer` mixin for entities contains the modular functionality for how to handle hunger levels. If an entity has some kind of `Actor` mixin that comes with an `act()` method in addition to the FoodConsumer mixin, that `act()` method can check to see if the acting entity has the mixin (`this.hasMixin('FoodConsumer')`) and if it does, decrement its hunger level on its turn. 133 | 134 | Listeners are properties of Mixins, and can be thought of as ways to unify functionality for events. These listeners are triggered by calling the `raiseEvent(event)` method on a DynamicGlyph. Example: two mixins, CorpseDropper and ItemDropper, have the listener 'onDeath': 135 | 136 | ``` 137 | listeners: { 138 | 'onDeath': function() { 139 | ... 140 | } 141 | } 142 | ``` 143 | 144 | When I trigger `entity.raiseEvent('onDeath')` for an entity with both these mixins, that entity will drop an item and a corpse. The beauty of this system is that the mixins don't have to know about each other, and don't depend on each other. 145 | 146 | `Game.Entity` and `Game.Item` are both dynamic glyphs. 147 | 148 | Tiles, entities, and items are all defined as templates in their respective repositories. 149 | 150 | ### `Game.AI` and `Game.AI.Tasks` 151 | 152 | Entities that have the `Game.EntityMixins.AIActor` mixin will take their turns according to the AI behaviors that are assigned to them. I haven't written a lot of content for these modules, but the bones work. Here's the structure: 153 | 154 | `Game.AI.` will be a function that takes an entity as an argument. Based on attributes of that entity, the behavior will try to intelligently perform tasks, which will be found in `Game.AI.Tasks.`. These ill also be functions that take an entity as a plugin. 155 | 156 | ## How to Contribute 157 | 158 | PRs are welcome! Feel free to report bugs and request features using the issues tab in the GitHub repository. 159 | 160 | I would also love for you to add in your own code under the `examples/` folder. Heck, feel free to put your whole game in if you want. Just put it in it's own folder: `examples//`. If you make an improvement or have something that you think could benefit the core files (`src/`), I'd love to check it out. The goal is for these files to be as generic as possible, so if it's not quite generic enough I might ask that your code find its home in the `examples/` folder. Either way I would love contributions! 161 | 162 | ## TODOs 163 | 164 | * Add option to install ROT.js via Bower 165 | * Update example directory to include latest examples 166 | * Update to use require, cleanup index.html 167 | * Themed example sets, with the ability to override src files where appropriate: sci-fi, high-fantasy, traditional, 7drl, etc. 168 | * Bring over templating system from Justice 169 | * Bring over equipment system from Monster Hunter RL? 170 | * Add in random item generation. 171 | -------------------------------------------------------------------------------- /bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hobgoblin", 3 | "homepage": "https://github.com/jakofranko/hobgoblinjs", 4 | "authors": [ 5 | "Jake Franklin " 6 | ], 7 | "description": "A JavaScript framework for ROT.js for creating Roguelike games in the browser", 8 | "main": "hobgoblin.js", 9 | "moduleType": [], 10 | "keywords": [ 11 | "roguelike", 12 | "rot.js" 13 | ], 14 | "license": "MIT", 15 | "ignore": [ 16 | "**/.*", 17 | "node_modules", 18 | "bower_components", 19 | "test", 20 | "tests" 21 | ], 22 | "dependencies": { 23 | "rot.js": "^0.5.0" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /examples/entities.js: -------------------------------------------------------------------------------- 1 | Game.EntityRepository = new Game.Repository('entities', Game.Entity); 2 | 3 | Game.PlayerTemplate = { 4 | name: 'human (you)', 5 | type: 'player', 6 | character: '@', 7 | foreground: 'white', 8 | maxHp: 40, 9 | attackValue: 10, 10 | sightRadius: 6, 11 | inventorySlots: 22, 12 | mixins: [ 13 | Game.EntityMixins.Sight, 14 | Game.EntityMixins.PlayerActor, 15 | Game.EntityMixins.Destructible, 16 | Game.EntityMixins.Equipper, 17 | Game.EntityMixins.Attacker, 18 | Game.EntityMixins.FoodConsumer, 19 | Game.EntityMixins.InventoryHolder, 20 | Game.EntityMixins.MessageRecipient, 21 | Game.EntityMixins.PlayerStatGainer, 22 | Game.EntityMixins.Thrower, 23 | Game.EntityMixins.ExperienceGainer 24 | ] 25 | }; 26 | 27 | Game.EntityRepository.define('fungus', { 28 | name: 'fungus', 29 | type: 'creature', 30 | character: 'F', 31 | foreground: 'green', 32 | maxHp: 10, 33 | speed: 250, 34 | mixins: [ 35 | Game.EntityMixins.FungusActor, 36 | Game.EntityMixins.Destructible, 37 | Game.EntityMixins.ExperienceGainer, 38 | Game.EntityMixins.RandomStatGainer 39 | ] 40 | }); 41 | 42 | Game.EntityRepository.define('bat', { 43 | name: 'bat', 44 | type: 'creature', 45 | character: 'B', 46 | foreground: 'white', 47 | maxHp: 5, 48 | attackValue: 4, 49 | speed: 2000, 50 | mixins: [ 51 | Game.EntityMixins.AIActor, 52 | Game.EntityMixins.Attacker, 53 | Game.EntityMixins.CorpseDropper, 54 | Game.EntityMixins.Destructible, 55 | Game.EntityMixins.ExperienceGainer, 56 | Game.EntityMixins.RandomStatGainer 57 | ] 58 | }); 59 | 60 | Game.EntityRepository.define('newt', { 61 | name: 'newt', 62 | type: 'creature', 63 | character: ':', 64 | foreground: 'yellow', 65 | maxHp: 3, 66 | attackValue: 2, 67 | mixins: [ 68 | Game.EntityMixins.AIActor, 69 | Game.EntityMixins.Attacker, 70 | Game.EntityMixins.CorpseDropper, 71 | Game.EntityMixins.Destructible, 72 | Game.EntityMixins.ExperienceGainer, 73 | Game.EntityMixins.RandomStatGainer 74 | ] 75 | }); 76 | 77 | Game.EntityRepository.define('kobold', { 78 | name: 'kobold', 79 | type: 'monster', 80 | character: 'k', 81 | foreground: 'white', 82 | maxHp: 6, 83 | attackValue: 4, 84 | sightRadius: 5, 85 | ai: ['hunt', 'wander'], 86 | mixins: [ 87 | Game.EntityMixins.AIActor, 88 | Game.EntityMixins.Sight, 89 | Game.EntityMixins.Equipper, 90 | Game.EntityMixins.Attacker, 91 | Game.EntityMixins.Destructible, 92 | Game.EntityMixins.CorpseDropper, 93 | Game.EntityMixins.ExperienceGainer, 94 | Game.EntityMixins.RandomStatGainer 95 | ] 96 | }); 97 | 98 | Game.EntityRepository.define('goblin', { 99 | name: 'goblin', 100 | type: 'monster', 101 | character: 'g', 102 | foreground: 'limegreen', 103 | maxHp: 10, 104 | attackValue: 6, 105 | sightRadius: 8, 106 | ai: ['hunt', 'wander'], 107 | mixins: [ 108 | Game.EntityMixins.AIActor, 109 | Game.EntityMixins.Sight, 110 | Game.EntityMixins.Equipper, 111 | Game.EntityMixins.Attacker, 112 | Game.EntityMixins.Destructible, 113 | Game.EntityMixins.CorpseDropper, 114 | Game.EntityMixins.ExperienceGainer, 115 | Game.EntityMixins.RandomStatGainer 116 | ] 117 | }); 118 | 119 | Game.EntityRepository.define('giant zombie', { 120 | name: 'giant zombie', 121 | type: 'monster', 122 | character: 'Z', 123 | foreground: 'teal', 124 | maxHp: 30, 125 | attackValue: 8, 126 | defenseValue: 5, 127 | level: 5, 128 | sightRadius: 6, 129 | mixins: [ 130 | Game.EntityMixins.GiantZombieActor, 131 | Game.EntityMixins.Sight, 132 | Game.EntityMixins.Attacker, 133 | Game.EntityMixins.Destructible, 134 | Game.EntityMixins.CorpseDropper, 135 | Game.EntityMixins.ExperienceGainer 136 | ] 137 | }, { 138 | disableRandomCreation: true 139 | }); 140 | 141 | Game.EntityRepository.define('slime', { 142 | name: 'slime', 143 | type: 'monster', 144 | character: 's', 145 | foreground: 'lightGreen', 146 | maxHp: 10, 147 | attackValue: 5, 148 | sightRadius: 3, 149 | ai: ['hunt', 'wander'], 150 | mixins: [ 151 | Game.EntityMixins.AIActor, 152 | Game.EntityMixins.Sight, 153 | Game.EntityMixins.Attacker, 154 | Game.EntityMixins.Destructible, 155 | Game.EntityMixins.CorpseDropper, 156 | Game.EntityMixins.ExperienceGainer, 157 | Game.EntityMixins.RandomStatGainer 158 | ] 159 | }); -------------------------------------------------------------------------------- /examples/items.js: -------------------------------------------------------------------------------- 1 | Game.ItemRepository = new Game.Repository('items', Game.Item); 2 | 3 | Game.ItemRepository.define('rock', { 4 | name: 'rock', 5 | character: '*', 6 | foreground: 'white', 7 | attackValue: 2, 8 | throwable: true, 9 | stackable: true, 10 | mixins: [Game.ItemMixins.Throwable, Game.ItemMixins.Stackable] 11 | }); 12 | 13 | Game.ItemRepository.define('apple', { 14 | name: 'apple', 15 | character: '%', 16 | foreground: 'red', 17 | foodValue: 50, 18 | stackable: true, 19 | mixins: [Game.ItemMixins.Edible, Game.ItemMixins.Stackable] 20 | }); 21 | 22 | Game.ItemRepository.define('melon', { 23 | name: 'melon', 24 | character: '%', 25 | foreground: 'lightgreen', 26 | foodValue: 35, 27 | consumptions: 4, 28 | mixins: [Game.ItemMixins.Edible] 29 | }); 30 | 31 | Game.ItemRepository.define('corpse', { 32 | name: 'corpse', 33 | character: '%', 34 | foodValue: 75, 35 | consumptions: 1, 36 | mixins: [Game.ItemMixins.Edible] 37 | }, { 38 | disableRandomCreation: true 39 | }); 40 | 41 | // Weapons 42 | Game.ItemRepository.define('dagger', { 43 | name: 'dagger', 44 | character: ')', 45 | foreground: 'gray', 46 | attackValue: 5, 47 | wieldable: true, 48 | throwable: true, 49 | mixins: [Game.ItemMixins.Equippable, Game.ItemMixins.Throwable] 50 | }, { 51 | disableRandomCreation: true 52 | }); 53 | 54 | Game.ItemRepository.define('sword', { 55 | name: 'sword', 56 | character: ')', 57 | foreground: 'white', 58 | attackValue: 10, 59 | wieldable: true, 60 | mixins: [Game.ItemMixins.Equippable] 61 | }, { 62 | disableRandomCreation: true 63 | }); 64 | 65 | Game.ItemRepository.define('staff', { 66 | name: 'staff', 67 | character: ')', 68 | foreground: 'yellow', 69 | attackValue: 5, 70 | defenseValue: 3, 71 | wieldable: true, 72 | mixins: [Game.ItemMixins.Equippable] 73 | }, { 74 | disableRandomCreation: true 75 | }); 76 | 77 | // Wearables 78 | Game.ItemRepository.define('tunic', { 79 | name: 'tunic', 80 | character: '[', 81 | foreground: 'green', 82 | defenseValue: 2, 83 | wearable: true, 84 | mixins: [Game.ItemMixins.Equippable] 85 | }, { 86 | disableRandomCreation: true 87 | }); 88 | 89 | Game.ItemRepository.define('chainmail', { 90 | name: 'chainmail', 91 | character: '[', 92 | foreground: 'white', 93 | defenseValue: 4, 94 | wearable: true, 95 | mixins: [Game.ItemMixins.Equippable] 96 | }, { 97 | disableRandomCreation: true 98 | }); 99 | 100 | Game.ItemRepository.define('platemail', { 101 | name: 'platemail', 102 | character: '[', 103 | foreground: 'aliceblue', 104 | defenseValue: 6, 105 | wearable: true, 106 | mixins: [Game.ItemMixins.Equippable] 107 | }, { 108 | disableRandomCreation: true 109 | }); 110 | 111 | Game.ItemRepository.define('pumpkin', { 112 | name: 'pumpkin', 113 | character: '%', 114 | foreground: 'orange', 115 | foodValue: 50, 116 | attackValue: 2, 117 | defenseValue: 2, 118 | wearable: true, 119 | wieldable: true, 120 | throwable: true, 121 | mixins: [Game.ItemMixins.Edible, Game.ItemMixins.Equippable, Game.ItemMixins.Throwable] 122 | }); 123 | -------------------------------------------------------------------------------- /examples/screens.js: -------------------------------------------------------------------------------- 1 | // Start splash screen 2 | Game.Screen.startScreen = new Game.Screen.basicScreen({ 3 | enter: function() { console.log('Entered teh start screen'); }, 4 | exit: function() { console.log('Exited the start screen'); }, 5 | render: function(display) { 6 | var w = Game.getScreenWidth(); 7 | var h = Game.getScreenHeight(); 8 | // Render prompt to the screen 9 | display.drawText((w/2) - 17, 5, "%c{yellow}[Your Game]%c{white}, a JavaScript Roguelike"); 10 | display.drawText((w/2) - 15, 6, "Press [?] at any time for help"); 11 | display.drawText((w/2) - 12, 8, "Press [Enter] to start!"); 12 | }, 13 | handleInput: function(inputType, inputData) { 14 | // When [Enter] is pressed, go to the play screen 15 | if(inputType === 'keydown' && inputData.keyCode === ROT.VK_RETURN) { 16 | Game.switchScreen(Game.Screen.loadScreen); 17 | } 18 | } 19 | }); 20 | 21 | // Displayed while files are loading and things are being generated 22 | Game.Screen.loadScreen = new Game.Screen.basicScreen({ 23 | enter: function() { 24 | // Register test modules 25 | Game.loadProgress.registerModule('Map'); 26 | Game.loadProgress.registerModule('Map', 'Lava'); 27 | Game.loadProgress.registerModule('Map', 'Water'); 28 | Game.loadProgress.registerModule('Map', 'Gold'); 29 | Game.loadProgress.registerModule('Monsters'); 30 | Game.loadProgress.registerModule('Items'); 31 | }, 32 | exit: function() {}, 33 | render: function(display) { 34 | var w = Game.getScreenWidth(); 35 | var h = Game.getScreenHeight(); 36 | var progress = Game.loadProgress.getProgress(); 37 | 38 | // 100 being the max progress number 39 | var barWidthMax = (w - 2) / 100; // -2 to account for end brackets 40 | 41 | // Due to an anomaly with l and rpad, 0 will add a pad, 1 will not (since 42 | // it gets the diff) so, if barWidth or barDiff are 0, then default to 1. 43 | var barWidth = progress * barWidthMax || 1; 44 | if(barWidth === 100 * barWidthMax) 45 | barWidth -= 1; // So as to account for the cap char 46 | var barDiff = (100 * barWidthMax) - barWidth || 1; 47 | var bar = "[".rpad("=", barWidth); 48 | var end = "]".lpad(" ", barDiff); 49 | var progressBar = bar + end; // The length of this string should always be 78 (or w - 2) 50 | 51 | // Render prompt to the screen 52 | display.drawText((w/2) - 5, 5, "%c{yellow}Loading..."); 53 | display.drawText((w/2) - (progressBar.length / 2), 7, progressBar); 54 | if(progress < 1) 55 | display.drawText((w/2) - 15, 9, "Press [Enter] to begin loading!"); 56 | if(progress >= 100) 57 | display.drawText((w/2) - 24, 9, "Press [Enter] to play or [Escape] to load again!"); 58 | }, 59 | handleInput: function(inputType, inputData) { 60 | // Purely as a demo, not functionally loading anything 61 | var numModules = 5, 62 | iterations = numModules * 10, 63 | currIteration = 1; 64 | 65 | Game.loadProgress.startModule('Map'); 66 | function loadModules() { 67 | switch(currIteration % numModules) { 68 | case 0: 69 | Game.loadProgress.startSubmodule('Map', 'Lava'); 70 | Game.loadProgress.updateSubmodule('Map', 'Lava', currIteration * 2); 71 | break; 72 | case 1: 73 | Game.loadProgress.startSubmodule('Map', 'Water'); 74 | Game.loadProgress.updateSubmodule('Map', 'Water', currIteration * 2); 75 | break; 76 | case 2: 77 | Game.loadProgress.startSubmodule('Map', 'Gold'); 78 | Game.loadProgress.updateSubmodule('Map', 'Gold', currIteration * 2); 79 | break; 80 | case 3: 81 | Game.loadProgress.startModule('Monsters'); 82 | Game.loadProgress.updateModule('Monsters', currIteration * 2); 83 | break; 84 | case 4: 85 | Game.loadProgress.startModule('Items'); 86 | Game.loadProgress.updateModule('Items', currIteration * 2); 87 | break; 88 | default: 89 | break; 90 | } 91 | 92 | if(currIteration === iterations) { 93 | Game.loadProgress.finishModule('Map'); 94 | Game.loadProgress.finishModule('Monsters'); 95 | Game.loadProgress.finishModule('Items'); 96 | clearInterval(window.intervalID); 97 | } else { 98 | currIteration++; 99 | } 100 | 101 | Game.refresh(); 102 | } 103 | 104 | if(inputType == 'keydown') { 105 | if(Game.loadProgress.getProgress() < 100) { 106 | if(inputData.keyCode === ROT.VK_RETURN) 107 | window.intervalID = setInterval(loadModules, 50); 108 | else if(inputData.keyCode === ROT.VK_ESCAPE) 109 | clearInterval(window.intervalID); 110 | } else { 111 | if(inputData.keyCode === ROT.VK_RETURN) 112 | Game.switchScreen(Game.Screen.playScreen); 113 | else if(inputData.keyCode === ROT.VK_ESCAPE) 114 | window.intervalID = setInterval(loadModules, 50); 115 | } 116 | } 117 | } 118 | }); 119 | // Main play screen 120 | Game.Screen.playScreen = new Game.Screen.basicScreen({ 121 | _player: null, 122 | _gameEnded: false, 123 | _subScreen: null, 124 | enter: function() { 125 | var width = 100; 126 | var height = 48; 127 | var depth = 6; 128 | 129 | // Create our map from the tiles and player 130 | this._player = new Game.Entity(Game.PlayerTemplate); 131 | var map = new Game.Map(width, height, depth, this._player); 132 | // Start the map's engine 133 | map.getEngine().start(); 134 | }, 135 | exit: function() { console.log("Exited play screen."); }, 136 | render: function(display) { 137 | // Render subscreen if there is one 138 | if (this._subScreen) { 139 | this._subScreen.render(display); 140 | return; 141 | } 142 | 143 | // Otherwise, procede as usual... 144 | var screenWidth = Game.getScreenWidth(); 145 | var screenHeight = Game.getScreenHeight(); 146 | 147 | // Render the tiles 148 | this.renderTiles(display); 149 | 150 | // Get the messages in the player's queue and render them 151 | var messages = this._player.getMessages(); 152 | var messageY = 0; 153 | for (var i = 0; i < messages.length; i++) { 154 | // Draw each message, adding the number of lines 155 | messageY += display.drawText( 156 | 0, 157 | messageY, 158 | '%c{white}%b{black}' + messages[i] 159 | ); 160 | } 161 | 162 | // Render player HP 163 | var stats = '%c{white}%b{black}'; 164 | stats += String.format( 165 | 'HP: %s/%s Level: %s XP: %s', 166 | this._player.getHp(), 167 | this._player.getMaxHp(), 168 | this._player.getLevel(), 169 | this._player.getExperience() 170 | ); 171 | display.drawText(0, screenHeight, stats); 172 | 173 | // Render hunger state 174 | var hungerState = this._player.getHungerState(); 175 | display.drawText(screenWidth - hungerState.length, screenHeight, hungerState); 176 | }, 177 | move: function(dX, dY, dZ) { 178 | var newX = this._player.getX() + dX; 179 | var newY = this._player.getY() + dY; 180 | var newZ = this._player.getZ() + dZ; 181 | this._player.tryMove(newX, newY, newZ, this._player.getMap()); 182 | }, 183 | handleInput: function(inputType, inputData) { 184 | // If the game is over, enter will bring the user to the losing screen. 185 | if(this._gameEnded) { 186 | if (inputType === 'keydown' && inputData.keyCode === ROT.VK_RETURN) { 187 | Game.switchScreen(Game.Screen.loseScreen); 188 | } 189 | // Return to make sure the user can't still play 190 | return; 191 | } 192 | // Handle subscreen input if there is one 193 | if (this._subScreen) { 194 | this._subScreen.handleInput(inputType, inputData); 195 | return; 196 | } 197 | 198 | var command = Game.Input.handleInput('playScreen', inputType, inputData); 199 | var unlock = command ? command(this._player) : false; 200 | 201 | if(unlock) 202 | this._player.getMap().getEngine().unlock(); 203 | else 204 | Game.refresh(); 205 | }, 206 | getScreenOffsets: function() { 207 | // Make sure we still have enough space to fit an entire game screen 208 | var topLeftX = Math.max(0, this._player.getX() - (Game.getScreenWidth() / 2)); 209 | // Make sure we still have enough space to fit an entire game screen 210 | topLeftX = Math.min(topLeftX, this._player.getMap().getWidth() - Game.getScreenWidth()); 211 | // Make sure the y-axis doesn't above the top bound 212 | var topLeftY = Math.max(0, this._player.getY() - (Game.getScreenHeight() / 2)); 213 | // Make sure we still have enough space to fit an entire game screen 214 | topLeftY = Math.min(topLeftY, this._player.getMap().getHeight() - Game.getScreenHeight()); 215 | return { 216 | x: topLeftX, 217 | y: topLeftY 218 | }; 219 | }, 220 | renderTiles: function(display) { 221 | var screenWidth = Game.getScreenWidth(); 222 | var screenHeight = Game.getScreenHeight(); 223 | var offsets = this.getScreenOffsets(); 224 | var topLeftX = offsets.x; 225 | var topLeftY = offsets.y; 226 | // This object will keep track of all visible map cells 227 | var visibleCells = {}; 228 | // Store this._player.getMap() and player's z to prevent losing it in callbacks 229 | var map = this._player.getMap(); 230 | var currentDepth = this._player.getZ(); 231 | // Find all visible cells and update the object 232 | map.getFov(currentDepth).compute( 233 | this._player.getX(), this._player.getY(), 234 | this._player.getSightRadius(), 235 | function(x, y, radius, visibility) { 236 | visibleCells[x + "," + y] = true; 237 | // Mark cell as explored 238 | map.setExplored(x, y, currentDepth, true); 239 | }); 240 | // Iterate through visible map cells 241 | for (var x = topLeftX; x < topLeftX + screenWidth; x++) { 242 | for (var y = topLeftY; y < topLeftY + screenHeight; y++) { 243 | if (map.isExplored(x, y, currentDepth)) { 244 | // Fetch the glyph for the tile and render it to the screen 245 | // at the offset position. 246 | var glyph = map.getTile(x, y, currentDepth); 247 | var foreground = glyph.getForeground(); 248 | // If we are at a cell that is in the field of vision, we need 249 | // to check if there are items or entities. 250 | if (visibleCells[x + ',' + y]) { 251 | // Check for items first, since we want to draw entities 252 | // over items. 253 | var items = map.getItemsAt(x, y, currentDepth); 254 | // If we have items, we want to render the top most item 255 | if (items) { 256 | glyph = items[items.length - 1]; 257 | } 258 | // Check if we have an entity at the position 259 | if (map.getEntityAt(x, y, currentDepth)) { 260 | glyph = map.getEntityAt(x, y, currentDepth); 261 | } 262 | // Update the foreground color in case our glyph changed 263 | foreground = glyph.getForeground(); 264 | } else { 265 | // Since the tile was previously explored but is not 266 | // visible, we want to change the foreground color to 267 | // dark gray. 268 | foreground = 'darkGray'; 269 | } 270 | 271 | display.draw( 272 | x - topLeftX, 273 | y - topLeftY, 274 | glyph.getChar(), 275 | foreground, 276 | glyph.getBackground()); 277 | } 278 | } 279 | } 280 | 281 | // Render the entities 282 | var entities = this._player.getMap().getEntities(); 283 | for (var key in entities) { 284 | var entity = entities[key]; 285 | if (visibleCells[entity.getX() + ',' + entity.getY()]) { 286 | // Only render the entity if they would show up on the screen 287 | if(entity.getX() < topLeftX + screenWidth && 288 | entity.getX() >= topLeftX && 289 | entity.getY() < topLeftY + screenHeight && 290 | entity.getY() >= topLeftY && 291 | entity.getZ() == this._player.getZ()) { 292 | display.draw( 293 | entity.getX() - topLeftX, 294 | entity.getY() - topLeftY, 295 | entity.getChar(), 296 | entity.getForeground(), 297 | entity.getBackground() 298 | ); 299 | } 300 | } 301 | } 302 | }, 303 | setGameEnded: function(gameEnded) { 304 | this._gameEnded = gameEnded; 305 | }, 306 | getSubScreen: function() { 307 | return this._subScreen; 308 | }, 309 | setSubScreen: function(subScreen) { 310 | this._subScreen = subScreen; 311 | Game.refresh(); 312 | }, 313 | showItemsSubScreen: function(subScreen, items, emptyMessage) { 314 | if (items && subScreen.setup(this._player, items) > 0) { 315 | this.setSubScreen(subScreen); 316 | } else { 317 | Game.sendMessage(this._player, emptyMessage); 318 | Game.refresh(); 319 | } 320 | } 321 | }); 322 | 323 | // Inventory sub-screens 324 | Game.Screen.inventoryScreen = new Game.Screen.ItemListScreen({ 325 | caption: 'Inventory', 326 | canSelect: false 327 | }); 328 | Game.Screen.pickupScreen = new Game.Screen.ItemListScreen({ 329 | caption: 'Choose the items you wish to pickup', 330 | canSelect: true, 331 | canSelectMultipleItems: true, 332 | ok: function(selectedItems) { 333 | // Try to pick up all items, messaging the player if they couldn't all be picked up. 334 | if (!this._player.pickupItems(Object.keys(selectedItems))) { 335 | Game.sendMessage(this._player, "Your inventory is full! Not all items were picked up."); 336 | } 337 | return true; 338 | } 339 | }); 340 | Game.Screen.dropScreen = new Game.Screen.ItemListScreen({ 341 | caption: 'Choose the item you wish to drop', 342 | canSelect: true, 343 | canSelectMultipleItems: false, 344 | ok: function(selectedItems) { 345 | // Drop the selected item 346 | this._player.dropItem(Object.keys(selectedItems)[0]); 347 | return true; 348 | } 349 | }); 350 | Game.Screen.eatScreen = new Game.Screen.ItemListScreen({ 351 | caption: 'Choose the item you wish to eat', 352 | canSelect: true, 353 | canSelectMultipleItems: false, 354 | isAcceptable: function(item) { 355 | return item && item.hasMixin('Edible') && item !== this._player._armor && item !== this._player._weapon; 356 | }, 357 | ok: function(selectedItems) { 358 | // Eat the item, removing it if there are no consumptions remaining. 359 | var key = Object.keys(selectedItems)[0]; 360 | var item = selectedItems[key]; 361 | Game.sendMessage(this._player, "You eat %s.", [item.describeThe()]); 362 | item.eat(this._player); 363 | if (!item.hasRemainingConsumptions()) { 364 | this._player.removeItem(key); 365 | } 366 | return true; 367 | } 368 | }); 369 | Game.Screen.wieldScreen = new Game.Screen.ItemListScreen({ 370 | caption: 'Choose the item you wish to wield', 371 | canSelect: true, 372 | canSelectMultipleItems: false, 373 | hasNoItemOption: true, 374 | isAcceptable: function(item) { 375 | return item && item.hasMixin('Equippable') && item.isWieldable(); 376 | }, 377 | ok: function(selectedItems) { 378 | // Check if we selected 'no item' 379 | var keys = Object.keys(selectedItems); 380 | if (keys.length === 0) { 381 | this._player.unwield(); 382 | Game.sendMessage(this._player, "You are empty handed.") 383 | } else { 384 | // Make sure to unequip the item first in case it is the armor. 385 | var item = selectedItems[keys[0]]; 386 | this._player.unequip(item); 387 | this._player.wield(item); 388 | Game.sendMessage(this._player, "You are wielding %s.", [item.describeA()]); 389 | } 390 | return true; 391 | } 392 | }); 393 | Game.Screen.wearScreen = new Game.Screen.ItemListScreen({ 394 | caption: 'Choose the item you wish to wear', 395 | canSelect: true, 396 | canSelectMultipleItems: false, 397 | hasNoItemOption: true, 398 | isAcceptable: function(item) { 399 | return item && item.hasMixin('Equippable') && item.isWearable(); 400 | }, 401 | ok: function(selectedItems) { 402 | // Check if we selected 'no item' 403 | var keys = Object.keys(selectedItems); 404 | if (keys.length === 0) { 405 | this._player.unwield(); 406 | Game.sendMessage(this._player, "You are not wearing anthing.") 407 | } else { 408 | // Make sure to unequip the item first in case it is the weapon. 409 | var item = selectedItems[keys[0]]; 410 | this._player.unequip(item); 411 | this._player.wear(item); 412 | Game.sendMessage(this._player, "You are wearing %s.", [item.describeA()]); 413 | } 414 | return true; 415 | } 416 | }); 417 | Game.Screen.examineScreen = new Game.Screen.ItemListScreen({ 418 | caption: 'Choose the item you wish to examine', 419 | canSelect: true, 420 | canSelectMultipleItems: false, 421 | isAcceptable: function(item) { 422 | return true; 423 | }, 424 | ok: function(selectedItems) { 425 | var keys = Object.keys(selectedItems); 426 | if (keys.length > 0) { 427 | var item = selectedItems[keys[0]]; 428 | var description = "It's %s"; 429 | var details = item.details(); 430 | if(details && details != "") { 431 | description += " (%s)."; 432 | Game.sendMessage(this._player, description, 433 | [ 434 | item.describeA(false), 435 | item.details() 436 | ]); 437 | } else { 438 | Game.sendMessage(this._player, description, [item.describeA(false)]); 439 | } 440 | 441 | } 442 | return true; 443 | } 444 | }); 445 | Game.Screen.throwScreen = new Game.Screen.ItemListScreen({ 446 | caption: 'Choose the item you wish to throw', 447 | canSelect: true, 448 | canSelectMultipleItems: false, 449 | isAcceptable: function(item) { 450 | if(!item || !item.hasMixin('Throwable')) { 451 | return false; 452 | } else if(item.hasMixin('Equippable') && (item.isWielded() || item.isWorn())) { 453 | return false 454 | } else { 455 | return true; 456 | } 457 | }, 458 | ok: function(selectedItems) { 459 | var offsets = Game.Screen.playScreen.getScreenOffsets(); 460 | // Go to the targetting screen 461 | Game.Screen.throwTargetScreen.setup(this._player, this._player.getX(), this._player.getY(), offsets.x, offsets.y); 462 | this._player.setThrowing(Object.keys(selectedItems)[0]); 463 | Game.Screen.playScreen.setSubScreen(Game.Screen.throwTargetScreen); 464 | return; 465 | } 466 | }); 467 | 468 | // Target-based screens 469 | Game.Screen.lookScreen = new Game.Screen.TargetBasedScreen({ 470 | captionFunction: function(x, y) { 471 | var z = this._player.getZ(); 472 | var map = this._player.getMap(); 473 | // If the tile is explored, we can give a better caption 474 | if (map.isExplored(x, y, z)) { 475 | // If the tile isn't explored, we have to check if we can actually 476 | // see it before testing if there's an entity or item. 477 | if (this._visibleCells[x + ',' + y]) { 478 | var items = map.getItemsAt(x, y, z); 479 | // If we have items, we want to render the top most item 480 | if (items) { 481 | var item = items[items.length - 1]; 482 | return String.format('%s - %s (%s)', 483 | item.getRepresentation(), 484 | item.describeA(true), 485 | item.details()); 486 | // Else check if there's an entity 487 | } else if (map.getEntityAt(x, y, z)) { 488 | var entity = map.getEntityAt(x, y, z); 489 | return String.format('%s - %s (%s)', 490 | entity.getRepresentation(), 491 | entity.describeA(true), 492 | entity.details()); 493 | } 494 | } 495 | // If there was no entity/item or the tile wasn't visible, then use 496 | // the tile information. 497 | return String.format('%s - %s', 498 | map.getTile(x, y, z).getRepresentation(), 499 | map.getTile(x, y, z).getDescription()); 500 | 501 | } else { 502 | var nullTile = Game.TileRepository.create('null'); 503 | // If the tile is not explored, show the null tile description. 504 | return String.format('%s - %s', 505 | nullTile.getRepresentation(), 506 | nullTile.getDescription()); 507 | } 508 | } 509 | }); 510 | Game.Screen.throwTargetScreen = new Game.Screen.TargetBasedScreen({ 511 | captionFunction: function(x, y, points) { 512 | var throwing = this._player.getItems()[this._player.getThrowing()]; 513 | var throwingSkill = this._player.getThrowingSkill(); 514 | var entity = this._player.getMap().getEntityAt(x, y, this._player.getZ()); 515 | console.log(entity); 516 | var string = String.format("You are throwing %s", throwing.describeA()); 517 | if(entity) { 518 | string += String.format(" at %s", entity.describeA()); 519 | } 520 | if(points.length > throwingSkill) { 521 | string += " - Might not do as much damage at this range" 522 | } 523 | return string; 524 | }, 525 | okFunction: function(x, y) { 526 | this._player.throwItem(this._player.getThrowing(), x, y); 527 | return true; 528 | } 529 | }); 530 | 531 | // Menu-based screens 532 | Game.Screen.actionMenu = new Game.Screen.MenuScreen({ 533 | caption: 'Action Menu', 534 | buildMenuItems: function() { 535 | var adjacentCoords = Game.Geometry.getCircle(this._player.getX(), this._player.getY(), 1), 536 | map = this._player.getMap(), 537 | z = this._player.getZ(), 538 | actions = []; 539 | 540 | // Populate a list of actions with which to build the menu 541 | for(var i = 0; i < adjacentCoords.length; i++) { 542 | var coords = adjacentCoords[i].split(","), 543 | x = coords[0], 544 | y = coords[1]; 545 | 546 | var entity = map.getEntityAt(x, y, z), 547 | items = map.getItemsAt(x, y, z); 548 | 549 | if(entity) { 550 | var entityActions = entity.raiseEvent('action', this._player); 551 | if(entityActions) actions.push(entityActions); 552 | } 553 | if(items) { 554 | for(var j = 0; j < items.length; j++) { 555 | var itemActions = items[j].raiseEvent('action', this._player); 556 | if(itemActions) actions.push(itemActions); 557 | } 558 | } 559 | } 560 | 561 | // Iterate through the actions, building out the _menuItems and _menuActions arrays 562 | for(var k = 0; k < actions.length; k++) { 563 | var glyphActions = actions[k]; // An array of action objects 564 | for (var l = 0; l < glyphActions.length; l++) { 565 | // An object of action name/functions pairs returned by each relevant item-mixin listener 566 | var mixinActions = glyphActions[l]; 567 | for(var actionName in mixinActions) { 568 | this._menuItems.push(actionName); 569 | this._menuActions.push(mixinActions[actionName]); 570 | } 571 | } 572 | } 573 | } 574 | }); 575 | 576 | // Help screen 577 | Game.Screen.helpScreen = new Game.Screen.basicScreen({ 578 | enter: function(display) {}, 579 | exit: function(display) {}, 580 | render: function(display) { 581 | var text = '[Your Roguelike] Help'; 582 | var border = '---------------'; 583 | var y = 0; 584 | display.drawText(Game.getScreenWidth() / 2 - text.length / 2, y++, text); 585 | display.drawText(Game.getScreenWidth() / 2 - text.length / 2, y++, border); 586 | y += 3; 587 | display.drawText(0, y++, 'Arrow keys to move'); 588 | display.drawText(0, y++, '[<] to go up stairs'); 589 | display.drawText(0, y++, '[>] to go down stairs'); 590 | display.drawText(0, y++, '[,] to pick up items'); 591 | display.drawText(0, y++, '[d] to drop items'); 592 | display.drawText(0, y++, '[e] to eat items'); 593 | display.drawText(0, y++, '[w] to wield items'); 594 | display.drawText(0, y++, '[W] to wear items'); 595 | display.drawText(0, y++, '[t] to throw items'); 596 | display.drawText(0, y++, '[x] to examine items'); 597 | display.drawText(0, y++, '[;] to look around you'); 598 | display.drawText(0, y++, '[?] to show this help screen'); 599 | y += 3; 600 | text = '--- press any key to continue ---'; 601 | display.drawText(Game.getScreenWidth() / 2 - text.length / 2, y++, text); 602 | }, 603 | handleInput: function(inputType, inputData) { 604 | Game.Screen.playScreen.setSubScreen(null); 605 | } 606 | }); 607 | 608 | 609 | // Level-up screen 610 | Game.Screen.gainStatScreen = new Game.Screen.basicScreen({ 611 | enter: function(entity) { 612 | // Must be called before rendering. 613 | this._entity = entity; 614 | this._options = entity.getStatOptions(); 615 | Game.Screen.playScreen.setSubScreen(Game.Screen.gainStatScreen); 616 | }, 617 | exit: function() { 618 | Game.Screen.playScreen.setSubScreen(undefined); 619 | }, 620 | render: function(display) { 621 | var letters = 'abcdefghijklmnopqrstuvwxyz'; 622 | display.drawText(0, 0, 'Choose a stat to increase: '); 623 | 624 | // Iterate through each of our options 625 | for (var i = 0; i < this._options.length; i++) { 626 | display.drawText(0, 2 + i, letters.substring(i, i + 1) + ' - ' + this._options[i][0]); 627 | } 628 | 629 | // Render remaining stat points 630 | display.drawText(0, 4 + this._options.length, "Remaining points: " + this._entity.getStatPoints()); 631 | }, 632 | handleInput: function(inputType, inputData) { 633 | if (inputType === 'keydown') { 634 | // If a letter was pressed, check if it matches to a valid option. 635 | if (inputData.keyCode >= ROT.VK_A && inputData.keyCode <= ROT.VK_Z) { 636 | // Check if it maps to a valid item by subtracting 'a' from the character 637 | // to know what letter of the alphabet we used. 638 | var index = inputData.keyCode - ROT.VK_A; 639 | if (this._options[index]) { 640 | // Call the stat increasing function 641 | this._options[index][1].call(this._entity); 642 | // Decrease stat points 643 | this._entity.setStatPoints(this._entity.getStatPoints() - 1); 644 | // If we have no stat points left, exit the screen, else refresh 645 | if (this._entity.getStatPoints() === 0) { 646 | this.exit(); 647 | } else { 648 | Game.refresh(); 649 | } 650 | } 651 | } 652 | } 653 | } 654 | }); 655 | 656 | // Define our winning screen 657 | Game.Screen.winScreen = new Game.Screen.basicScreen({ 658 | enter: function() { console.log("Entered win screen."); }, 659 | exit: function() { console.log("Exited win screen."); }, 660 | render: function(display) { 661 | // Render our prompt to the screen 662 | for (var i = 0; i < 22; i++) { 663 | // Generate random background colors 664 | var r = Math.round(Math.random() * 255); 665 | var g = Math.round(Math.random() * 255); 666 | var b = Math.round(Math.random() * 255); 667 | var background = ROT.Color.toRGB([r, g, b]); 668 | display.drawText(2, i + 1, "%b{" + background + "}You win!"); 669 | } 670 | }, 671 | handleInput: function(inputType, inputData) { 672 | if(inputType === 'keydown' && inputData.keyCode === ROT.VK_RETURN) { 673 | Game.switchScreen(Game.Screen.playScreen); 674 | } 675 | } 676 | }); 677 | 678 | // Define our winning screen 679 | Game.Screen.loseScreen = new Game.Screen.basicScreen({ 680 | enter: function() { console.log("Entered lose screen."); }, 681 | exit: function() { console.log("Exited lose screen."); }, 682 | render: function(display) { 683 | // Render our prompt to the screen 684 | for (var i = 0; i < 22; i++) { 685 | display.drawText(2, i + 1, "%b{red}You lose! :("); 686 | } 687 | }, 688 | handleInput: function(inputType, inputData) { 689 | if(inputType === 'keydown' && inputData.keyCode === ROT.VK_RETURN) { 690 | Game.Screen.playScreen.setGameEnded(true); 691 | Game.switchScreen(Game.Screen.startScreen); 692 | } 693 | } 694 | }); 695 | -------------------------------------------------------------------------------- /examples/tiles.js: -------------------------------------------------------------------------------- 1 | Game.TileRepository = new Game.Repository('tiles', Game.Tile); 2 | 3 | Game.TileRepository.define('null', { 4 | name: 'null', 5 | description: '(unknown)' 6 | }); 7 | Game.TileRepository.define('air', { 8 | name: 'air', 9 | description: 'Empty space' 10 | }); 11 | Game.TileRepository.define('floor', { 12 | name: 'floor', 13 | character: '.', 14 | walkable: true, 15 | blocksLight: false, 16 | description: 'The floor' 17 | }); 18 | Game.TileRepository.define('grass', { 19 | name: 'grass', 20 | character: '"', 21 | foreground: Game.Palette.green, 22 | walkable: true, 23 | blocksLight: false, 24 | description: 'A patch of grass' 25 | }); 26 | Game.TileRepository.define('wall', { 27 | name: 'wall', 28 | character: '#', 29 | foreground: Game.Palette.grey, 30 | blocksLight: true, 31 | outerWall: true, 32 | description: 'A wall' 33 | }); 34 | Game.TileRepository.define('stairsUp', { 35 | name: 'stairsUp', 36 | character: '<', 37 | foreground: Game.Palette.pink, 38 | walkable: true, 39 | blocksLight: false, 40 | description: 'A staircase leading upwards' 41 | }); 42 | Game.TileRepository.define('stairsDown', { 43 | name: 'stairsDown', 44 | character: '>', 45 | foreground: Game.Palette.pink, 46 | walkable: true, 47 | blocksLight: false, 48 | description: 'A staircase leading downwards' 49 | }); 50 | Game.TileRepository.define('water', { 51 | name: 'water', 52 | character: '~', 53 | foreground: Game.Palette.blue, 54 | walkable: false, 55 | blocksLight: false, 56 | description: 'Clear blue water' 57 | }); 58 | Game.TileRepository.define('door', { 59 | name: 'door', 60 | character: '+', 61 | foreground: Game.Palette.darkgrey, 62 | walkable: true, 63 | blocksLight: false, 64 | description: "A door" 65 | }); -------------------------------------------------------------------------------- /hobgoblin.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node --harmony 2 | 'use strict'; 3 | 4 | // https://medium.freecodecamp.com/writing-command-line-applications-in-nodejs-2cf8327eee2#.196im2niz 5 | // https://www.npmjs.com/package/commander 6 | // https://developer.atlassian.com/blog/2015/11/scripting-with-node/ 7 | 8 | const program = require('commander'), 9 | chalk = require('chalk'), 10 | ProgressBar = require('progress'), 11 | fs = require('fs'), 12 | p = require('path'); 13 | 14 | var hobgoblinDir = p.dirname(require.main.filename); 15 | var srcDir = hobgoblinDir + '/src/'; 16 | var exampleDir = hobgoblinDir + '/examples/'; 17 | 18 | program 19 | .version('1.2.0') 20 | .command('init') 21 | .description('Initialize framework in current directory') 22 | .option('-e, --examples', 'Pull in all example files') 23 | .action(function(options) { 24 | // If the js dir doesn't exist, create it 25 | if(!fs.existsSync('js/')) 26 | fs.mkdir('js'); 27 | 28 | // Copy over the main files 29 | let contents = fs.readdirSync(srcDir); 30 | let jsDir = fs.readdirSync('js'); 31 | let jsFiles = []; 32 | for (var i = 0; i < contents.length; i++) { 33 | if(jsDir.indexOf(contents[i]) > -1) { 34 | console.log(chalk.blue(contents[i] + ' already exists. Skipping...')); 35 | } else { 36 | let filePath = srcDir + contents[i]; 37 | let fileSize = fs.statSync(filePath).size; 38 | let readStream = fs.createReadStream(filePath); 39 | let newFile = fs.createWriteStream('js/' + contents[i]); 40 | 41 | let bar = new ProgressBar("Writing file " + contents[i] + ": [:bar] :percent", {total: fileSize}); 42 | 43 | readStream.on('data', (chunk) => { 44 | bar.tick(chunk.length); 45 | newFile.write(chunk); 46 | }); 47 | readStream.on('end', () => { 48 | newFile.end(); 49 | }); 50 | readStream.on('error', (err) => { 51 | newFile.end(); 52 | console.error(err); 53 | }); 54 | } 55 | } 56 | 57 | // Copy examples, if specified 58 | if(options.examples) { 59 | let examples = fs.readdirSync(exampleDir); 60 | let exampleFiles = examples.map((file) => { 61 | return 'example-' + file; 62 | }); 63 | for (var i = 0; i < exampleFiles.length; i++) { 64 | if(jsDir.indexOf(exampleFiles[i]) > -1) { 65 | console.log(chalk.blue(exampleFiles[i] + ' already exists. Skipping...')); 66 | } else { 67 | let filePath = exampleDir + examples[i]; // read from unmodified file name 68 | let fileSize = fs.statSync(filePath).size; 69 | let readStream = fs.createReadStream(filePath); 70 | let newFile = fs.createWriteStream('js/' + exampleFiles[i]); 71 | 72 | let bar = new ProgressBar("Writing file " + exampleFiles[i] + ": [:bar] :percent", {total: fileSize}); 73 | 74 | readStream.on('data', (chunk) => { 75 | bar.tick(chunk.length); 76 | newFile.write(chunk); 77 | }); 78 | readStream.on('end', () => { 79 | newFile.end(); 80 | }); 81 | readStream.on('error', (err) => { 82 | newFile.end(); 83 | console.error(err); 84 | }); 85 | } 86 | } 87 | jsFiles = contents.concat(exampleFiles); 88 | } 89 | 90 | if(fs.existsSync('index.html')) 91 | console.log(chalk.blue("index.html already exists. Skipping...")); 92 | else 93 | fs.writeFile('index.html', generateIndexHTML(jsFiles), (err) => { 94 | if(err) console.error(err); 95 | else console.log(chalk.bold.green("done!")); 96 | }); 97 | }); 98 | 99 | function generateIndexHTML(jsFiles) { 100 | var jsOrder = [ 101 | "game.js", 102 | "loader.js", 103 | "utilities.js", 104 | "palette.js", 105 | "geometry.js", 106 | "repository.js", 107 | "glyph.js", 108 | "glyph-dynamic.js", 109 | "tile.js", 110 | "tiles.js", 111 | "entity.js", 112 | "entity-mixins.js", 113 | "item.js", 114 | "item-mixins.js", 115 | "map.js", 116 | "screen.js", 117 | "ai.js", 118 | "ai-tasks.js", 119 | "example-screens.js", 120 | "example-tiles.js", 121 | "example-items.js", 122 | "example-entities.js", 123 | "commands.js", 124 | "input.js" 125 | ]; 126 | 127 | var html = '\n'; 128 | html += '\n'; 129 | html += '\n'; 130 | html += '\t\n'; 131 | html += '\t[Your Game] - A Roguelike\n'; 132 | for (var i = 0; i < jsFiles.length; i++) { 133 | if(jsOrder.indexOf(jsFiles[i]) < 0) 134 | throw new Error("This file must be ordered: " + jsFiles[i]); 135 | 136 | html += '\t\n'; 137 | } 138 | 139 | html += '\n'; 140 | html += '\n'; 141 | html += ''; 142 | 143 | return html; 144 | } 145 | 146 | program.parse(process.argv); 147 | -------------------------------------------------------------------------------- /package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hobgoblin", 3 | "version": "2.2.0", 4 | "lockfileVersion": 1, 5 | "requires": true, 6 | "dependencies": { 7 | "ansi-regex": { 8 | "version": "2.1.1", 9 | "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", 10 | "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=" 11 | }, 12 | "ansi-styles": { 13 | "version": "2.2.1", 14 | "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz", 15 | "integrity": "sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4=" 16 | }, 17 | "bower": { 18 | "version": "1.8.2", 19 | "resolved": "https://registry.npmjs.org/bower/-/bower-1.8.2.tgz", 20 | "integrity": "sha1-rfU1KcjUrwLvJPuNU0HBQZ0z4vc=" 21 | }, 22 | "chalk": { 23 | "version": "1.1.3", 24 | "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz", 25 | "integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=", 26 | "requires": { 27 | "ansi-styles": "2.2.1", 28 | "escape-string-regexp": "1.0.5", 29 | "has-ansi": "2.0.0", 30 | "strip-ansi": "3.0.1", 31 | "supports-color": "2.0.0" 32 | } 33 | }, 34 | "commander": { 35 | "version": "2.12.2", 36 | "resolved": "https://registry.npmjs.org/commander/-/commander-2.12.2.tgz", 37 | "integrity": "sha512-BFnaq5ZOGcDN7FlrtBT4xxkgIToalIIxwjxLWVJ8bGTpe1LroqMiqQXdA7ygc7CRvaYS+9zfPGFnJqFSayx+AA==" 38 | }, 39 | "escape-string-regexp": { 40 | "version": "1.0.5", 41 | "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", 42 | "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=" 43 | }, 44 | "has-ansi": { 45 | "version": "2.0.0", 46 | "resolved": "https://registry.npmjs.org/has-ansi/-/has-ansi-2.0.0.tgz", 47 | "integrity": "sha1-NPUEnOHs3ysGSa8+8k5F7TVBbZE=", 48 | "requires": { 49 | "ansi-regex": "2.1.1" 50 | } 51 | }, 52 | "progress": { 53 | "version": "1.1.8", 54 | "resolved": "https://registry.npmjs.org/progress/-/progress-1.1.8.tgz", 55 | "integrity": "sha1-4mDHj2Fhzdmw5WzD4Khd4Xx6V74=" 56 | }, 57 | "strip-ansi": { 58 | "version": "3.0.1", 59 | "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", 60 | "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=", 61 | "requires": { 62 | "ansi-regex": "2.1.1" 63 | } 64 | }, 65 | "supports-color": { 66 | "version": "2.0.0", 67 | "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz", 68 | "integrity": "sha1-U10EXOa2Nj+kARcIRimZXp3zJMc=" 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hobgoblin", 3 | "version": "2.2.0", 4 | "description": "A framework for writing roguelike games in JavaScript, implementing ROT.js", 5 | "main": "hobgoblin.js", 6 | "bin": { 7 | "hobgoblin": "./hobgoblin.js" 8 | }, 9 | "directories": { 10 | "example": "examples" 11 | }, 12 | "scripts": { 13 | "test": "echo \"Error: no test specified\" && exit 1" 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "git+https://github.com/jakofranko/hobgoblinjs.git" 18 | }, 19 | "keywords": [ 20 | "roguelike", 21 | "ROT.js", 22 | "rot", 23 | "ascii", 24 | "dungeon", 25 | "game", 26 | "dev", 27 | "games", 28 | "game", 29 | "framework" 30 | ], 31 | "author": "Jake Franklin ", 32 | "license": "ISC", 33 | "bugs": { 34 | "url": "https://github.com/jakofranko/hobgoblinjs/issues" 35 | }, 36 | "homepage": "https://github.com/jakofranko/hobgoblinjs#readme", 37 | "dependencies": { 38 | "bower": "^1.8.0", 39 | "chalk": "^1.1.3", 40 | "commander": "^2.9.0", 41 | "progress": "^1.1.8" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/ai-tasks.js: -------------------------------------------------------------------------------- 1 | // Notes on defining tasks: 2 | // - The actors should be determined by the Game.AI entry almost always; no logic for determining who the actors are should exist in a task 3 | 4 | Game.AI.Tasks = {}; 5 | 6 | // This task should be a wrapper to handle how an AI entity 7 | // will attack the target based on behavior and equipment 8 | // TODO: implement logic switches based on entity behavior 9 | // TODO: account for ranged weapons (need to separate the Attacker 10 | // mixin into melee and ranged versions (like in MonsterHunterRL)) 11 | Game.AI.Tasks.attack = function(entity, target) { 12 | if(entity.hasMixin('Attacker')) 13 | entity.attack(target); 14 | }; 15 | Game.AI.Tasks.approach = function(entity, target) { 16 | // If no one is around, then just wander 17 | if(!target) { 18 | this.wander(entity); 19 | return false; 20 | } else if(entity.getPath().length > 0) { 21 | var step = entity.getNextStep(); 22 | entity.tryMove(step[0], step[1], step[2]); 23 | return false; 24 | } else { 25 | // If we are adjacent to the target, then we have successfully approached it. 26 | // TODO: if I'm not mistaken, this enforces a topology 4 and doesn't account for diagnally adjacent 27 | var distance = Math.abs(target.getX() - entity.getX()) + Math.abs(target.getY() - entity.getY()); 28 | if(distance === 1) { 29 | return true; 30 | } 31 | 32 | // Generate the path and move to the first tile. 33 | var source = entity; 34 | var z = source.getZ(); 35 | var path = new ROT.Path.AStar(target.getX(), target.getY(), function(x, y) { 36 | // If an entity is present at the tile, can't move there. 37 | var entity = source.getMap().getEntityAt(x, y, z); 38 | if (entity && entity !== target && entity !== source) { 39 | return false; 40 | } 41 | return source.getMap().getTile(x, y, z).isWalkable(); 42 | }, {topology: 4}); 43 | // Once we've gotten the path, we want to store a number of steps equal 44 | // to half the distance between the entity and the target, skipping the 45 | // first coordinate because that is the entity's starting location 46 | var count = 0; 47 | var entityPath = []; 48 | path.compute(source.getX(), source.getY(), function(x, y) { 49 | if(count > 0 && count <= distance / 2) 50 | entityPath.push([x, y, z]); 51 | count++; 52 | }); 53 | 54 | // Update the entity's path and make the first step 55 | entity.setPath(entityPath); 56 | var step = entity.getNextStep(); 57 | 58 | // TODO: This might cause some entities to freeze... 59 | if(step && step.length) 60 | entity.tryMove(step[0], step[1], step[2]); 61 | return false; 62 | } 63 | }; 64 | 65 | Game.AI.Tasks.wander = function(entity) { 66 | // Flip coin to determine if moving by 1 in the positive or negative direction 67 | var moveOffset = (Math.round(Math.random()) === 1) ? 1 : -1; 68 | // Flip coin to determine if moving in x direction or y direction 69 | if (Math.round(Math.random()) === 1) { 70 | entity.tryMove(entity.getX() + moveOffset, entity.getY(), entity.getZ()); 71 | } else { 72 | entity.tryMove(entity.getX(), entity.getY() + moveOffset, entity.getZ()); 73 | } 74 | }; -------------------------------------------------------------------------------- /src/ai.js: -------------------------------------------------------------------------------- 1 | Game.AI = {}; 2 | 3 | // TODO: Implement demeanor/behaviors ('ranged', 'agressive', 'cautious' etc.) 4 | Game.AI.hunt = function(entity) { 5 | var target = entity.getTarget(); 6 | if(!target) { 7 | var enemiesInSight = entity.scanForEnemies(); 8 | if(enemiesInSight.length < 1) 9 | return false; // This task has failed, so on to the next 10 | else { 11 | target = enemiesInSight[0]; 12 | entity.setTarget(enemiesInSight[0]); 13 | } 14 | } 15 | var adjacent = Game.AI.Tasks.approach(entity, target); 16 | if(adjacent) 17 | Game.AI.Tasks.attack(entity, target); 18 | 19 | return true; 20 | }; 21 | Game.AI.wander = function(entity) { 22 | Game.AI.Tasks.wander(entity); 23 | return true; 24 | }; -------------------------------------------------------------------------------- /src/commands.js: -------------------------------------------------------------------------------- 1 | // Inspiration for this architecture was drawn from Robert Nystrom's book 'Game Programming Patterns,' 2 | // and specifically from his chapter on the Command Pattern: http://gameprogrammingpatterns.com/command.html 3 | // 4 | // A command is something that encapsulates functionality in a way that decouples input from action. 5 | // There are different ways of implementing this; one way is to have a command simply execute another function, 6 | // and return nothing. With a little bit of support for binding functions to commands, this may be all we 7 | // need. However, another way this can be implemented is to have a command RETURN a function, so that commands 8 | // can be manipulated independently. I think I will go with this approach, since it can allow an entity to 9 | // undo/redo actions, as well as allow us to decouple the entity and the command, allowing the player to 10 | // control monsters and vice-versa. 11 | // 12 | // There are two other variations of this pattern that are relevant to our purposes. The first is to have 13 | // the returned function take an actor (entity) as a parameter. This let's us perform the command on any 14 | // actor we desire at a later time. The other variation binds the entity to the returned function. The 15 | // advantage of this set up is that it would allow undo/redo behavior with less overhead. For now, I 16 | // think that the first variety will be fine for our needs, since keeping track of a stream of commands 17 | // that can be undone/redone isn't a big feature for most roguelikes, and could be implemented with little 18 | // difficulty for one that wanted to support this functionality. 19 | // 20 | // This architecture will involve two parts: 1) a 'handler' (for now, player input) that will take an input, 21 | // and return a command to be executed later, and 2) a list of commands. 22 | // 23 | // Final note: the function that is returned by a command should return a boolean value that will 24 | // indicate whether or not the command should unlock the game engine, thereby ending the entity's turn. 25 | 26 | Game.Commands = {}; 27 | 28 | Game.Commands.moveCommand = function(diffX, diffY, diffZ) { 29 | return function(entity) { 30 | return entity.tryMove(entity.getX() - diffX, entity.getY() - diffY, entity.getZ() - diffZ); 31 | }; 32 | }; 33 | 34 | Game.Commands.showScreenCommand = function(screen, mainScreen) { 35 | return function(entity) { 36 | if(screen.setup) 37 | screen.setup(entity); 38 | mainScreen.setSubScreen(screen); 39 | }; 40 | }; 41 | 42 | Game.Commands.showItemScreenCommand = function(itemScreen, mainScreen, noItemsMessage, getItems) { 43 | return function(entity) { 44 | // Items screens' setup method will always return the number of items they will display. 45 | // This can be used to determine a prompt if no items will display in the menu 46 | if(!itemScreen.setup) 47 | throw new Error('item screens require a setup method.'); 48 | 49 | var items = getItems ? getItems(entity) : entity.getItems(); 50 | if(!items) items = []; 51 | var acceptableItems = itemScreen.setup(entity, items); 52 | if(acceptableItems > 0) 53 | mainScreen.setSubScreen(itemScreen); 54 | else { 55 | Game.sendMessage(entity, noItemsMessage); 56 | Game.refresh(); 57 | } 58 | }; 59 | }; 60 | 61 | Game.Commands.ItemScreenExecuteOkCommand = function(mainScreen, key) { 62 | return function() { 63 | var letters = ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n", "o", "p", "q", "r", "s", "t", "u", "v", "w", "x", "y", "z"]; 64 | var index = letters.indexOf(key.toLowerCase()); 65 | var subScreen = mainScreen.getSubScreen(); 66 | 67 | // If enter is pressed, execute the ok functions 68 | if(key === "Enter") 69 | return subScreen.executeOkFunction(); 70 | 71 | // If the 'no item' option is selected 72 | if(subScreen._canSelectItem && subScreen._hasNoItemOption && key === "0") { 73 | subScreen._selectedIndices = {}; 74 | return subScreen.executeOkFunction(); 75 | } 76 | 77 | // Do nothing if a letter isn't pressed 78 | if(index === -1) 79 | return false; 80 | 81 | if(subScreen._items[index]) { 82 | // If multiple selection is allowed, toggle the selection status, 83 | // else select the item and exit the screen 84 | if(subScreen._canSelectMultipleItems) { 85 | if(subScreen._selectedIndices[index]) 86 | delete subScreen._selectedIndices[index]; 87 | else 88 | subScreen._selectedIndices[index] = true; 89 | 90 | } else { 91 | subScreen._selectedIndices[index] = true; 92 | return subScreen.executeOkFunction(); 93 | } 94 | } 95 | } 96 | } 97 | 98 | Game.Commands.showTargettingScreenCommand = function(targettingScreen, mainScreen) { 99 | return function(entity) { 100 | // Make sure the x-axis doesn't go above the top bound 101 | var topLeftX = Math.max(0, entity.getX() - (Game.getScreenWidth() / 2)); 102 | // Make sure we still have enough space to fit an entire game screen 103 | var offsetX = Math.min(topLeftX, entity.getMap().getWidth() - Game.getScreenWidth()); 104 | // Make sure the y-axis doesn't go above the top bound 105 | var topLeftY = Math.max(0, entity.getY() - (Game.getScreenHeight() / 2)); 106 | // Make sure we still have enough space to fit an entire game screen 107 | var offsetY = Math.min(topLeftY, entity.getMap().getHeight() - Game.getScreenHeight()); 108 | 109 | targettingScreen.setup(entity, entity.getX(), entity.getY(), offsetX, offsetY); 110 | mainScreen.setSubScreen(targettingScreen); 111 | }; 112 | }; 113 | 114 | // Does not end player turn, so don't return 115 | Game.Commands.moveCursorCommand = function(mainScreen, offsetX, offsetY) { 116 | return function() { 117 | mainScreen.getSubScreen().moveCursor(offsetX, offsetY); 118 | } 119 | }; 120 | 121 | Game.Commands.TargetBasedScreenOkCommand = function(mainScreen) { 122 | return function() { 123 | return mainScreen.getSubScreen().executeOkFunction(); 124 | } 125 | } 126 | 127 | Game.Commands.moveMenuIndexCommand = function(mainScreen, amount) { 128 | return function() { 129 | var subScreen = mainScreen.getSubScreen(); 130 | subScreen.moveMenuIndex(amount); 131 | } 132 | } 133 | 134 | Game.Commands.MenuScreenOkCommand = function(mainScreen) { 135 | return function() { 136 | var subScreen = mainScreen.getSubScreen(); 137 | return subScreen.executeOkFunction(); 138 | } 139 | } 140 | 141 | Game.Commands.removeSubScreenCommand = function(mainScreen) { 142 | return function() { 143 | mainScreen.setSubScreen(undefined); 144 | }; 145 | }; 146 | 147 | Game.Commands.nullCommand = function() { 148 | return function(){}; 149 | }; 150 | -------------------------------------------------------------------------------- /src/entity-mixins.js: -------------------------------------------------------------------------------- 1 | // From http://www.codingcookies.com/2013/04/20/building-a-roguelike-in-javascript-part-4/ 2 | Game.EntityMixins = {}; 3 | 4 | // TODO: Implement functions for scanning for friends/enemies and getting the nearest of them 5 | Game.EntityMixins.AIActor = { 6 | name: 'AIActor', 7 | groupName: 'Actor', 8 | init: function(template) { 9 | // Load tasks 10 | this._ai = template['ai'] || ['wander']; 11 | this._behavior = template['behavior'] || 'aggressive'; 12 | this._target = template['target'] || null; 13 | this._path = template['path'] || []; 14 | this._friends = template['friends'] || [this.getName(), this.getType()]; 15 | this._enemies = template['enemies'] || ['player']; 16 | }, 17 | act: function() { 18 | // Iterate through all our behaviors 19 | for (var i = 0; i < this._ai.length; i++) { 20 | var success = Game.AI[this._ai[i]](this); // Do the AI behavior, passing in the entity 21 | 22 | // If this was a success, then break out of the loop 23 | if(success) 24 | return true; 25 | 26 | } 27 | }, 28 | getBehavior: function() { 29 | return this._behavior; 30 | }, 31 | getTarget: function() { 32 | return this._target; 33 | }, 34 | getPath: function() { 35 | return this._path; 36 | }, 37 | getNextStep: function() { 38 | return this._path.shift(); 39 | }, 40 | getFriends: function() { 41 | return this._friends; 42 | }, 43 | scanForFriends: function() { 44 | if(this.hasMixin('Sight')) { 45 | var friends = []; 46 | var radius = this.getSightRadius(); 47 | var entities = this.getMap().getEntitiesWithinRadius(this.getX(), this.getY(), this.getZ(), radius); 48 | for (var i = 0; i < entities.length; i++) { 49 | if((this._friends.indexOf(entities[i].getName()) > -1 || this._friends.indexOf(entities[i].getType()) > -1) && this.canSee(entities[i])) 50 | friends.push(entities[i]) 51 | } 52 | return friends; 53 | } 54 | }, 55 | getEnemies: function() { 56 | return this._enemies; 57 | }, 58 | scanForEnemies: function() { 59 | if(this.hasMixin('Sight')) { 60 | var enemies = []; 61 | var radius = this.getSightRadius(); 62 | var entities = this.getMap().getEntitiesWithinRadius(this.getX(), this.getY(), this.getZ(), radius); 63 | for (var i = 0; i < entities.length; i++) { 64 | if((this._enemies.indexOf(entities[i].getName()) > -1 || this._enemies.indexOf(entities[i].getType()) > -1) && this.canSee(entities[i])) 65 | enemies.push(entities[i]); 66 | } 67 | return enemies; 68 | } 69 | }, 70 | setTarget: function(target) { 71 | this._target = target; 72 | }, 73 | setPath: function(path) { 74 | this._path = path; 75 | }, 76 | }; 77 | 78 | Game.EntityMixins.Attacker = { 79 | name: 'Attacker', 80 | groupName: 'Attacker', 81 | init: function(template) { 82 | this._attackValue = template['attackValue'] || 1; 83 | }, 84 | getAttackValue: function() { 85 | var modifier = 0; 86 | // If we can equip items, then have to take into 87 | // consideration weapon and armor 88 | if (this.hasMixin(Game.EntityMixins.Equipper)) { 89 | if (this.getWeapon()) { 90 | modifier += this.getWeapon().getAttackValue(); 91 | } 92 | if (this.getArmor()) { 93 | modifier += this.getArmor().getAttackValue(); 94 | } 95 | } 96 | return this._attackValue + modifier; 97 | }, 98 | attack: function(target) { 99 | // Only remove the entity if they were attackable 100 | if (target.hasMixin('Destructible')) { 101 | var attack = this.getAttackValue(); 102 | var defense = target.getDefenseValue(); 103 | var max = Math.max(0, attack - defense); 104 | var damage = 1 + Math.floor(Math.random() * max); 105 | 106 | Game.sendMessage(this, 'You strike the %s for %s damage!', [target.getName(), damage]); 107 | Game.sendMessage(target, 'The %s strikes you for %s damage!', [this.getName(), damage]); 108 | target.takeDamage(this, damage); 109 | } 110 | }, 111 | increaseAttackValue: function(value) { 112 | // If no value was passed, default to 2. 113 | value = value || 2; 114 | // Add to the attack value. 115 | this._attackValue += value; 116 | Game.sendMessage(this, "You look stronger!"); 117 | }, 118 | listeners: { 119 | details: function() { 120 | return [{key: 'attack', value: this.getAttackValue()}]; 121 | } 122 | } 123 | }; 124 | Game.EntityMixins.CorpseDropper = { 125 | name: 'CorpseDropper', 126 | init: function(template) { 127 | // Chance of dropping a corpse (out of 100). 128 | this._corpseDropRate = template['corpseDropRate'] || 100; 129 | }, 130 | listeners: { 131 | onDeath: function(attacker) { 132 | // Check if we should drop a corpse. 133 | if (Math.round(Math.random() * 100) <= this._corpseDropRate) { 134 | // Create a new corpse item and drop it. 135 | this._map.addItem(this.getX(), this.getY(), this.getZ(), 136 | Game.ItemRepository.create('corpse', { 137 | name: this._name + ' corpse', 138 | foreground: this._foreground 139 | })); 140 | } 141 | } 142 | } 143 | }; 144 | Game.EntityMixins.Destructible = { 145 | name: 'Destructible', 146 | init: function(template) { 147 | this._maxHp = template['maxHp'] || 10; 148 | this._hp = template['hp'] || this._maxHp; 149 | this._defenseValue = template['defenseValue'] || 0; 150 | }, 151 | getDefenseValue: function() { 152 | var modifier = 0; 153 | // If we can equip items, then have to take into 154 | // consideration weapon and armor 155 | if (this.hasMixin(Game.EntityMixins.Equipper)) { 156 | if (this.getWeapon()) { 157 | modifier += this.getWeapon().getDefenseValue(); 158 | } 159 | if (this.getArmor()) { 160 | modifier += this.getArmor().getDefenseValue(); 161 | } 162 | } 163 | return this._defenseValue + modifier; 164 | }, 165 | getHp: function() { 166 | return this._hp; 167 | }, 168 | getMaxHp: function() { 169 | return this._maxHp; 170 | }, 171 | takeDamage: function(attacker, damage) { 172 | this._hp -= damage; 173 | if(this._hp <= 0) { 174 | Game.sendMessage(attacker, 'You kill the %s!', [this.getName()]); 175 | // Raise events 176 | this.raiseEvent('onDeath', attacker); 177 | attacker.raiseEvent('onKill', this); 178 | this.kill(); 179 | } 180 | }, 181 | setHp: function(hp) { 182 | this._hp = hp; 183 | }, 184 | increaseDefenseValue: function(value) { 185 | // If no value was passed, default to 2. 186 | value = value || 2; 187 | // Add to the defense value. 188 | this._defenseValue += value; 189 | Game.sendMessage(this, "You look tougher!"); 190 | }, 191 | increaseMaxHp: function(value) { 192 | // If no value was passed, default to 10. 193 | value = value || 10; 194 | // Add to both max HP and HP. 195 | this._maxHp += value; 196 | this._hp += value; 197 | Game.sendMessage(this, "You look healthier!"); 198 | }, 199 | listeners: { 200 | onGainLevel: function() { 201 | // Heal the entity. 202 | this.setHp(this.getMaxHp()); 203 | }, 204 | details: function() { 205 | return [ 206 | {key: 'defense', value: this.getDefenseValue()}, 207 | {key: 'hp', value: this.getHp()} 208 | ]; 209 | } 210 | } 211 | }; 212 | Game.EntityMixins.Equipper = { 213 | name: 'Equipper', 214 | init: function(template) { 215 | this._weapon = null; 216 | this._armor = null; 217 | }, 218 | wield: function(item) { 219 | this._weapon = item; 220 | item.wield(); 221 | }, 222 | unwield: function() { 223 | if(this._weapon) 224 | this._weapon.unwield(); 225 | this._weapon = null; 226 | }, 227 | wear: function(item) { 228 | this._armor = item; 229 | item.wear(); 230 | }, 231 | takeOff: function() { 232 | if(this._armor) 233 | this._armor.takeOff(); 234 | this._armor = null; 235 | }, 236 | getWeapon: function() { 237 | return this._weapon; 238 | }, 239 | getArmor: function() { 240 | return this._armor; 241 | }, 242 | unequip: function(item) { 243 | // Helper function to be called before getting rid of an item. 244 | if (this._weapon === item) { 245 | this.unwield(); 246 | } 247 | if (this._armor === item) { 248 | this.takeOff(); 249 | } 250 | } 251 | }; 252 | Game.EntityMixins.ExperienceGainer = { 253 | name: 'ExperienceGainer', 254 | init: function(template) { 255 | this._level = template['level'] || 1; 256 | this._experience = template['experience'] || 0; 257 | this._statPointsPerLevel = template['statPointsPerLevel'] || 1; 258 | this._statPoints = 0; 259 | // Determine what stats can be levelled up. 260 | this._statOptions = []; 261 | if (this.hasMixin('Attacker')) { 262 | this._statOptions.push(['Increase attack value', this.increaseAttackValue]); 263 | } 264 | if (this.hasMixin('Destructible')) { 265 | this._statOptions.push(['Increase defense value', this.increaseDefenseValue]); 266 | this._statOptions.push(['Increase max health', this.increaseMaxHp]); 267 | } 268 | if (this.hasMixin('Sight')) { 269 | this._statOptions.push(['Increase sight range', this.increaseSightRadius]); 270 | } 271 | if (this.hasMixin('Thrower')) { 272 | this._statOptions.push(['Increase throwing skill', this.increaseThrowingSkill]); 273 | } 274 | }, 275 | getLevel: function() { 276 | return this._level; 277 | }, 278 | getExperience: function() { 279 | return this._experience; 280 | }, 281 | getNextLevelExperience: function() { 282 | return (this._level * this._level) * 10; 283 | }, 284 | getStatPoints: function() { 285 | return this._statPoints; 286 | }, 287 | setStatPoints: function(statPoints) { 288 | this._statPoints = statPoints; 289 | }, 290 | getStatOptions: function() { 291 | return this._statOptions; 292 | }, 293 | giveExperience: function(points) { 294 | var statPointsGained = 0; 295 | var levelsGained = 0; 296 | // Loop until we've allocated all points. 297 | while (points > 0) { 298 | // Check if adding in the points will surpass the level threshold. 299 | if (this._experience + points >= this.getNextLevelExperience()) { 300 | // Fill our experience till the next threshold. 301 | var usedPoints = this.getNextLevelExperience() - this._experience; 302 | points -= usedPoints; 303 | this._experience += usedPoints; 304 | // Level up our entity! 305 | this._level++; 306 | levelsGained++; 307 | this._statPoints += this._statPointsPerLevel; 308 | statPointsGained += this._statPointsPerLevel; 309 | } else { 310 | // Simple case - just give the experience. 311 | this._experience += points; 312 | points = 0; 313 | } 314 | } 315 | // Check if we gained at least one level. 316 | if (levelsGained > 0) { 317 | Game.sendMessage(this, "You advance to level %s.", [this._level]); 318 | this.raiseEvent('onGainLevel'); 319 | } 320 | }, 321 | listeners: { 322 | onKill: function(victim) { 323 | var exp = victim.getMaxHp() + victim.getDefenseValue(); 324 | if (victim.hasMixin('Attacker')) { 325 | exp += victim.getAttackValue(); 326 | } 327 | // Account for level differences 328 | if (victim.hasMixin('ExperienceGainer')) { 329 | exp -= (this.getLevel() - victim.getLevel()) * 3; 330 | } 331 | // Only give experience if more than 0. 332 | if (exp > 0) { 333 | this.giveExperience(exp); 334 | } 335 | }, 336 | details: function() { 337 | return [{key: 'level', value: this.getLevel()}]; 338 | } 339 | } 340 | }; 341 | Game.EntityMixins.FoodConsumer = { 342 | name: 'FoodConsumer', 343 | init: function(template) { 344 | this._maxFullness = template['maxFullness'] || 1000; 345 | // Start halfway to max fullness if no default value 346 | this._fullness = template['fullness'] || (this._maxFullness / 2); 347 | // Number of points to decrease fullness by every turn. 348 | this._fullnessDepletionRate = template['fullnessDepletionRate'] || 1; 349 | }, 350 | addTurnHunger: function() { 351 | // Remove the standard depletion points 352 | this.modifyFullnessBy(-this._fullnessDepletionRate); 353 | }, 354 | modifyFullnessBy: function(points) { 355 | this._fullness = this._fullness + points; 356 | if (this._fullness <= 0) { 357 | this.kill("You have died of starvation!"); 358 | } else if (this._fullness > this._maxFullness) { 359 | this.kill("You choke and die!"); 360 | } 361 | }, 362 | getHungerState: function() { 363 | // Fullness points per percent of max fullness 364 | var perPercent = this._maxFullness / 100; 365 | // 5% of max fullness or less = starving 366 | if(this._fullness <= perPercent * 5) { 367 | return 'Starving'; 368 | // 25% of max fullness or less = hungry 369 | } else if (this._fullness <= perPercent * 25) { 370 | return 'Hungry'; 371 | // 95% of max fullness or more = oversatiated 372 | } else if (this._fullness >= perPercent * 95) { 373 | return 'Oversatiated'; 374 | // 75% of max fullness or more = full 375 | } else if (this._fullness >= perPercent * 75) { 376 | return 'Full'; 377 | // Anything else = not hungry 378 | } else { 379 | return 'Not Hungry'; 380 | } 381 | } 382 | }; 383 | Game.EntityMixins.FungusActor = { 384 | name: 'FungusActor', 385 | groupName: 'Actor', 386 | init: function() { 387 | this._growthsRemaining = 5; 388 | }, 389 | act: function() { 390 | if(this._growthsRemaining > 0) { 391 | if(Math.random() <= 0.02) { 392 | // Generate the coordinates of a random adjacent square by 393 | // generating an offset between [-1, 0, 1] for both the x and 394 | // y directions. To do this, we generate a number from 0-2 and then 395 | // subtract 1. 396 | var xOffset = Math.floor(Math.random() * 3) - 1; 397 | var yOffset = Math.floor(Math.random() * 3) - 1; 398 | // Make sure we aren't trying to spawn on the same tile as us 399 | if (xOffset != 0 || yOffset != 0) { 400 | // Check if we can actually spawn at that location, and if so 401 | // then we grow! 402 | if (this.getMap().isEmptyFloor(this.getX() + xOffset, this.getY() + yOffset, this.getZ())) { 403 | var entity = Game.EntityRepository.create('fungus'); 404 | entity.setPosition(this.getX() + xOffset, this.getY() + yOffset, this.getZ()); 405 | this.getMap().addEntity(entity); 406 | this._growthsRemaining--; 407 | 408 | // Send a message nearby! 409 | Game.sendMessageNearby(this.getMap(), entity.getX(), entity.getY(), entity.getZ(), 'The fungus is spreading!'); 410 | } 411 | } 412 | } 413 | } 414 | } 415 | }; 416 | Game.EntityMixins.InventoryHolder = { 417 | name: 'InventoryHolder', 418 | init: function(template) { 419 | // Default to 10 inventory slots. 420 | var inventorySlots = template['inventorySlots'] || 10; 421 | // Set up an empty inventory. 422 | this._items = new Array(inventorySlots); 423 | 424 | // If the template specifies items, put them in the entity's inventory 425 | if(template['items']) { 426 | for (var i = 0; i < template['items'].length; i++) { 427 | this._items[i] = Game.ItemRepository.create(template['items'][i]); 428 | } 429 | } 430 | }, 431 | getItems: function() { 432 | return this._items; 433 | }, 434 | getItem: function(i) { 435 | return this._items[i]; 436 | }, 437 | addItem: function(item) { 438 | // Try to find a slot, returning true only if we could add the item. 439 | 440 | // Check to see if we can stack the item unless we find an open slot first 441 | if(item.hasMixin('Stackable')) { 442 | for (var i = 0; i < this._items.length; i++) { 443 | if (!this._items[i]) { 444 | this._items[i] = item; 445 | return true; 446 | } else if(this._items[i].describe() == item.describe()) { 447 | this._items[i].addToStack(); 448 | return true; 449 | } 450 | } 451 | } else { 452 | for (var i = 0; i < this._items.length; i++) { 453 | if (!this._items[i]) { 454 | this._items[i] = item; 455 | return true; 456 | } 457 | } 458 | } 459 | return false; 460 | }, 461 | removeItem: function(i, amount) { 462 | // If we can equip items, then make sure we unequip the item we are removing. 463 | if (this._items[i] && this.hasMixin(Game.EntityMixins.Equipper)) { 464 | this.unequip(this._items[i]); 465 | } 466 | 467 | // If the item is in a stack, decrement the stack amount 468 | if(this._items[i].hasMixin('Stackable') && this._items[i].amount() > 1) { 469 | this._items[i].removeFromStack(amount); 470 | } else { 471 | // Simply clear the inventory slot. 472 | this._items[i] = null; 473 | } 474 | }, 475 | canAddItem: function() { 476 | // Check if we have an empty slot. 477 | for (var i = 0; i < this._items.length; i++) { 478 | if (!this._items[i]) { 479 | return true; 480 | } 481 | } 482 | return false; 483 | }, 484 | pickupItems: function(indices) { 485 | // Allows the user to pick up items from the map, where indices is 486 | // the indices for the array returned by map.getItemsAt 487 | var mapItems = this._map.getItemsAt(this.getX(), this.getY(), this.getZ()); 488 | var added = 0; 489 | // Iterate through all indices. 490 | for (var i = 0; i < indices.length; i++) { 491 | // Try to add the item. If our inventory is not full, then splice the 492 | // item out of the list of items. In order to fetch the right item, we 493 | // have to offset the number of items already added. 494 | if (this.addItem(mapItems[indices[i] - added])) { 495 | mapItems.splice(indices[i] - added, 1); 496 | added++; 497 | } else { 498 | // Inventory is full 499 | break; 500 | } 501 | } 502 | // Update the map items 503 | this._map.setItemsAt(this.getX(), this.getY(), this.getZ(), mapItems); 504 | // Return true only if we added all items 505 | return added === indices.length; 506 | }, 507 | dropItem: function(i) { 508 | // Drops an item to the current map tile 509 | if (this._items[i]) { 510 | var amount = 0; 511 | if(this._items[i].hasMixin('Stackable')) { 512 | amount = this._item[i].amount(); 513 | } 514 | if (this._map) { 515 | this._map.addItem(this.getX(), this.getY(), this.getZ(), this._items[i]); 516 | } 517 | this.removeItem(i, amount); 518 | } 519 | } 520 | }; 521 | Game.EntityMixins.MessageRecipient = { 522 | name: 'MessageRecipient', 523 | init: function(template) { 524 | this._messages = []; 525 | }, 526 | receiveMessage: function(message) { 527 | this._messages.push(message); 528 | }, 529 | getMessages: function() { 530 | return this._messages; 531 | }, 532 | clearMessage: function(i) { 533 | this._messages.splice(i, 1); 534 | }, 535 | clearMessages: function() { 536 | this._messages = []; 537 | } 538 | }; 539 | Game.EntityMixins.PlayerActor = { 540 | name: 'PlayerActor', 541 | groupName: 'Actor', 542 | act: function() { 543 | if (this._acting) { 544 | return; 545 | } 546 | this._acting = true; 547 | this.addTurnHunger(); 548 | // Detect if the game is over 549 | if(!this.isAlive()) { 550 | Game.Screen.playScreen.setGameEnded(true); 551 | // Send a last message to the player 552 | Game.sendMessage(this, 'Press [Enter] to continue!'); 553 | } 554 | // Re-render the screen 555 | Game.refresh(); 556 | // Lock the engine and wait asynchronously 557 | // for the player to press a key. 558 | this.getMap().getEngine().lock(); 559 | this.clearMessages(); 560 | this._acting = false; 561 | } 562 | }; 563 | Game.EntityMixins.PlayerStatGainer = { 564 | name: 'PlayerStatGainer', 565 | groupName: 'StatGainer', 566 | listeners: { 567 | onGainLevel: function() { 568 | // Setup the gain stat screen and show it. 569 | Game.Screen.gainStatScreen.enter(this); 570 | Game.Screen.playScreen.setSubScreen(Game.Screen.gainStatScreen); 571 | } 572 | } 573 | }; 574 | Game.EntityMixins.RandomStatGainer = { 575 | name: 'RandomStatGainer', 576 | groupName: 'StatGainer', 577 | listeners: { 578 | onGainLevel: function() { 579 | var statOptions = this.getStatOptions(); 580 | // Randomly select a stat option and execute the callback for each stat point. 581 | while (this.getStatPoints() > 0) { 582 | // Call the stat increasing function with this as the context. 583 | statOptions.random()[1].call(this); 584 | this.setStatPoints(this.getStatPoints() - 1); 585 | } 586 | } 587 | } 588 | }; 589 | Game.EntityMixins.Sight = { 590 | name: 'Sight', 591 | groupName: 'Sight', 592 | init: function(template) { 593 | this._sightRadius = template['sightRadius'] || 5; 594 | }, 595 | getSightRadius: function() { 596 | return this._sightRadius; 597 | }, 598 | canSee: function(entity) { 599 | // If not on the same map or on different floors, then exit early 600 | if (!entity || this._map !== entity.getMap() || this._z !== entity.getZ()) { 601 | return false; 602 | } 603 | 604 | var otherX = entity.getX(); 605 | var otherY = entity.getY(); 606 | 607 | // If we're not in a square field of view, then we won't be in a real 608 | // field of view either. 609 | if ((otherX - this._x) * (otherX - this._x) + 610 | (otherY - this._y) * (otherY - this._y) > 611 | this._sightRadius * this._sightRadius) { 612 | return false; 613 | } 614 | 615 | // Compute the FOV and check if the coordinates are in there. 616 | // TODO: This should use the existing FOV isntead of re-computing 617 | var found = false; 618 | this.getMap().getFov(this.getZ()).compute( 619 | this.getX(), this.getY(), 620 | this.getSightRadius(), 621 | function(x, y, radius, visibility) { 622 | if (x === otherX && y === otherY) { 623 | found = true; 624 | } 625 | }); 626 | return found; 627 | }, 628 | increaseSightRadius: function(value) { 629 | // If no value was passed, default to 1. 630 | value = value || 1; 631 | // Add to sight radius. 632 | this._sightRadius += value; 633 | Game.sendMessage(this, "You are more aware of your surroundings!"); 634 | }, 635 | }; 636 | Game.EntityMixins.Thrower = { 637 | name: 'Thrower', 638 | init: function(template) { 639 | this._throwing = template['throwing'] || null; 640 | this._throwingSkill = template['throwingSkill'] || 1; 641 | }, 642 | getThrowing: function() { 643 | return this._throwing; 644 | }, 645 | getThrowingSkill: function() { 646 | return this._throwingSkill; 647 | }, 648 | setThrowing: function(i) { 649 | this._throwing = i; 650 | }, 651 | increaseThrowingSkill: function(value) { 652 | var value = value || 2; 653 | this._throwingSkill += 2; 654 | Game.sendMessage(this, "You feel better at throwing things!"); 655 | }, 656 | _getTarget: function(targetX, targetY) { 657 | var linePoints = Game.Geometry.getLine(this.getX(), this.getY(), targetX, targetY); 658 | var z = this.getZ(); 659 | // Check to see if any walls or other creatures other than the thrower in the path 660 | var end; 661 | var lastPoint; 662 | for (var i = 1; i < linePoints.length; i++) { 663 | if(!this.getMap().getTile(linePoints[i].x, linePoints[i].y, z).isWalkable()) { 664 | end = lastPoint; 665 | break; 666 | } else if(this.getMap().getEntityAt(linePoints[i].x, linePoints[i].y, z)) { 667 | end = linePoints[i]; 668 | break; 669 | } else { 670 | lastPoint = linePoints[i]; 671 | } 672 | }; 673 | 674 | // If nothing is in the way, the end point is targetX and targetY 675 | if(!end) { 676 | end = {x: targetX, y: targetY} 677 | } 678 | return {x: end.x, y: end.y, distance: linePoints.length}; 679 | }, 680 | throwItem: function(i, targetX, targetY) { 681 | // Select the item to be thrown by its index 682 | var item = this._items[i]; 683 | if(item.isThrowable()) { 684 | // Check to see if there is a destructible entity at targetX and targetY 685 | var target = this._getTarget(targetX, targetY); 686 | var entity = this.getMap().getEntityAt(target.x, target.y, this.getZ()); 687 | if(entity && entity.hasMixin('Destructible')) { 688 | // Entity has been found, calculate damage! 689 | var attack = this.getThrowingSkill() + item.getAttackValue(); 690 | var defense = entity.getDefenseValue(); 691 | // The distance penalty will decrease as the skill increases 692 | var distancePenalty = Math.floor(target.distance / this.getThrowingSkill()); 693 | var max = Math.max(0, attack - defense - distancePenalty); 694 | var damage = 1 + Math.floor(Math.random() * max); 695 | Game.sendMessage(this, "You throw %s at %s for %s damage!", [item.describeA(), entity.describeThe(), damage]); 696 | Game.sendMessage(entity, "%s throws %s at you!", [this.describeThe(), item.describeA()]); 697 | entity.takeDamage(this, damage); 698 | } else { 699 | Game.sendMessage(this, "You throw %s!", [item.describeA()]); 700 | } 701 | 702 | if(item.hasMixin('Stackable')) { 703 | // It's actually easer to just create a new object at the location because of weird pass-by-reference stuff that javascript does. 704 | var newItem = Game.ItemRepository.create(item.describe()); 705 | this.getMap().addItem(target.x, target.y, this.getZ(), newItem); 706 | } else { 707 | this.getMap().addItem(target.x, target.y, this.getZ(), item); 708 | } 709 | 710 | this.removeItem(i); 711 | } 712 | } 713 | } 714 | 715 | // For some reason, Game.extend has to be called after Game.EntityMixins.TaskActor is defined, since that's the thing it's trying to extend. 716 | Game.EntityMixins.GiantZombieActor = Game.extend(Game.EntityMixins.TaskActor, { 717 | init: function(template) { 718 | // Call the task actor init with the right tasks. 719 | Game.EntityMixins.TaskActor.init.call(this, Game.extend(template, { 720 | 'tasks' : ['growArm', 'spawnSlime', 'hunt', 'wander'] 721 | })); 722 | // We only want to grow the arm once. 723 | this._hasGrownArm = false; 724 | }, 725 | canDoTask: function(task) { 726 | // If we haven't already grown arm and HP <= 20, then we can grow. 727 | if (task === 'growArm') { 728 | return this.getHp() <= 20 && !this._hasGrownArm; 729 | // Spawn a slime only a 10% of turns. 730 | } else if (task === 'spawnSlime') { 731 | return Math.round(Math.random() * 100) <= 10; 732 | // Call parent canDoTask 733 | } else { 734 | return Game.EntityMixins.TaskActor.canDoTask.call(this, task); 735 | } 736 | }, 737 | growArm: function() { 738 | this._hasGrownArm = true; 739 | this.increaseAttackValue(5); 740 | // Send a message saying the zombie grew an arm. 741 | Game.sendMessageNearby(this.getMap(), 742 | this.getX(), this.getY(), this.getZ(), 743 | 'An extra arm appears on the giant zombie!'); 744 | }, 745 | spawnSlime: function() { 746 | // Generate a random position nearby. 747 | var xOffset = Math.floor(Math.random() * 3) - 1; 748 | var yOffset = Math.floor(Math.random() * 3) - 1; 749 | 750 | // Check if we can spawn an entity at that position. 751 | if (!this.getMap().isEmptyFloor(this.getX() + xOffset, this.getY() + yOffset, this.getZ())) { 752 | // If we cant, do nothing 753 | return; 754 | } 755 | // Create the entity 756 | var slime = Game.EntityRepository.create('slime'); 757 | slime.setX(this.getX() + xOffset); 758 | slime.setY(this.getY() + yOffset) 759 | slime.setZ(this.getZ()); 760 | this.getMap().addEntity(slime); 761 | }, 762 | listeners: { 763 | onDeath: function(attacker) { 764 | // Switch to win screen when killed! 765 | Game.switchScreen(Game.Screen.winScreen); 766 | } 767 | } 768 | }); -------------------------------------------------------------------------------- /src/entity.js: -------------------------------------------------------------------------------- 1 | Game.Entity = function(properties) { 2 | properties = properties || {}; 3 | Game.DynamicGlyph.call(this, properties); 4 | this._name = properties['name'] || ''; 5 | this._type = properties['type'] || null; 6 | this._alive = true; 7 | this._x = properties['x'] || 0; 8 | this._y = properties['y'] || 0; 9 | this._z = properties['z'] || 0; 10 | this._speed = properties['speed'] || 1000; 11 | this._map = null; 12 | }; 13 | // Make entities inherit all the functionality from glyphs 14 | Game.Entity.extend(Game.DynamicGlyph); 15 | 16 | Game.Entity.prototype.setX = function(x) { 17 | this._x = x; 18 | }; 19 | Game.Entity.prototype.setY = function(y) { 20 | this._y = y; 21 | }; 22 | Game.Entity.prototype.setZ = function(z) { 23 | this._z = z; 24 | }; 25 | Game.Entity.prototype.setSpeed = function(speed) { 26 | this._speed = speed; 27 | }; 28 | Game.Entity.prototype.setPosition = function(x, y, z) { 29 | var oldX = this._x; 30 | var oldY = this._y; 31 | var oldZ = this._z; 32 | // Update position 33 | this._x = x; 34 | this._y = y; 35 | this._z = z; 36 | 37 | // If the entity is on a map, notify the map that the entity has moved 38 | if(this._map) { 39 | this._map.updateEntityPosition(this, oldX, oldY, oldZ); 40 | } 41 | }; 42 | Game.Entity.prototype.getType = function() { 43 | return this._type; 44 | }; 45 | Game.Entity.prototype.getSpeed = function() { 46 | return this._speed; 47 | }; 48 | Game.Entity.prototype.getX = function() { 49 | return this._x; 50 | }; 51 | Game.Entity.prototype.getY = function() { 52 | return this._y; 53 | }; 54 | Game.Entity.prototype.getZ = function() { 55 | return this._z; 56 | }; 57 | Game.Entity.prototype.setMap = function(map) { 58 | this._map = map; 59 | }; 60 | Game.Entity.prototype.getMap = function() { 61 | return this._map; 62 | }; 63 | Game.Entity.prototype.tryMove = function(x, y, z, map) { 64 | // TODO: Account for existing entities on stair tiles when moving up and down 65 | // TODO: clean this up 66 | if(!map) { 67 | map = this.getMap(); 68 | } 69 | // Must use starting z 70 | var tile = map.getTile(x, y, this.getZ()); 71 | var target = map.getEntityAt(x, y, this.getZ()); 72 | // If our z level changed, check if we are on stair 73 | if(z < this.getZ()) { 74 | if(tile.describe() !== 'stairsUp') { 75 | Game.sendMessage(this, "You can't go up here!"); 76 | } else { 77 | Game.sendMessage(this, "You ascend to level %s!", [z + 1]); 78 | this.setPosition(x, y, z); 79 | return true; 80 | } 81 | } else if(z > this.getZ()) { 82 | if (tile.describe() !== 'stairsDown') { 83 | Game.sendMessage(this, "You can't go down here!"); 84 | } else { 85 | Game.sendMessage(this, "You descend to level %s!", [z + 1]); 86 | this.setPosition(x, y, z); 87 | return true; 88 | } 89 | } else if(target) { 90 | // An entity can only attack if the entity has the Attacker mixin and 91 | // either the entity or the target is the player. 92 | if (this.hasMixin('Attacker') && 93 | (this.hasMixin(Game.EntityMixins.PlayerActor) || target.hasMixin(Game.EntityMixins.PlayerActor))) { 94 | this.attack(target); 95 | return true; 96 | } 97 | 98 | // If not nothing we can do, but we can't move to the tile 99 | return false; 100 | } else if(tile.isWalkable()) { 101 | this.setPosition(x, y, z); 102 | // Notify the entity that there are items at this position 103 | var items = this.getMap().getItemsAt(x, y, z); 104 | if (items) { 105 | if (items.length === 1) { 106 | Game.sendMessage(this, "You see %s.", [items[0].describeA()]); 107 | } else { 108 | Game.sendMessage(this, "There are several objects here."); 109 | } 110 | } 111 | return true; 112 | } else if(tile.isDiggable()) { // TODO: get this logic into a mixin 113 | // Only dig if the the entity is the player 114 | if (this.hasMixin(Game.EntityMixins.PlayerActor)) { 115 | map.dig(x, y, z); 116 | return true; 117 | } 118 | // If not nothing we can do, but we can't move to the tile 119 | return false; 120 | } 121 | return false; 122 | }; 123 | Game.Entity.prototype.isAlive = function() { 124 | return this._alive; 125 | }; 126 | Game.Entity.prototype.kill = function(message) { 127 | // Only kill once! 128 | if (!this._alive) { 129 | return; 130 | } 131 | this._alive = false; 132 | if (message) { 133 | Game.sendMessage(this, message); 134 | } else { 135 | Game.sendMessage(this, "You have died!"); 136 | } 137 | 138 | // Check if the player died, and if so call their act method to prompt the user. 139 | if (this.hasMixin(Game.EntityMixins.PlayerActor)) { 140 | this.act(); 141 | } else { 142 | this.getMap().removeEntity(this); 143 | } 144 | }; 145 | Game.Entity.prototype.switchMap = function(newMap) { 146 | // If it's the same map, nothing to do! 147 | if (newMap === this.getMap()) { 148 | return; 149 | } 150 | this.getMap().removeEntity(this); 151 | // Clear the position 152 | this._x = 0; 153 | this._y = 0; 154 | this._z = 0; 155 | // Add to the new map 156 | newMap.addEntity(this); 157 | }; -------------------------------------------------------------------------------- /src/game.js: -------------------------------------------------------------------------------- 1 | var Game = { 2 | _display: null, 3 | _currentScreen: null, 4 | _screenWidth: 80, 5 | _screenHeight: 24, 6 | 7 | getDisplay: function() { 8 | return this._display; 9 | }, 10 | getScreenWidth: function() { 11 | return this._screenWidth; 12 | }, 13 | getScreenHeight: function() { 14 | return this._screenHeight; 15 | }, 16 | init: function() { 17 | // Initialize loader 18 | this.loadProgress = new Game.Loader( 19 | [] // Put module names here as elements of array 20 | ); 21 | // Add one to height for displaying stats 22 | this._display = new ROT.Display({width: this._screenWidth, height: this._screenHeight + 1}); 23 | // Create a helper function for binding to an event 24 | // and making it send it to the screen 25 | var game = this; // So that we don't lose this 26 | var bindEventToScreen = function(event) { 27 | window.addEventListener(event, function(e) { 28 | // When an event is received, send it to the 29 | // screen if there is one 30 | if (game._currentScreen !== null) { 31 | // Send the event type and data to the screen 32 | game._currentScreen.handleInput(event, e); 33 | } 34 | }); 35 | }; 36 | 37 | // Bind keyboard input events 38 | bindEventToScreen('keydown'); 39 | // bindEventToScreen('keyup'); 40 | // bindEventToScreen('keypress'); 41 | }, 42 | refresh: function() { 43 | // Clear the screen 44 | this._display.clear(); 45 | // Render the screen 46 | this._currentScreen.render(this._display); 47 | }, 48 | sendMessage: function(recipient, message, args) { 49 | // Make sure the recipient can receive messages 50 | if(recipient.hasMixin('MessageRecipient')) { 51 | // If args were passed, format the message 52 | // Elsewise, don't format the message 53 | if(args) { 54 | message = message.format.apply(message, args); 55 | } 56 | recipient.receiveMessage(message); 57 | } 58 | }, 59 | sendMessageNearby: function(map, centerX, centerY, centerZ, message, args) { 60 | // If args were passed, then we format the message, else 61 | // no formatting is necessary 62 | if(args) { 63 | message = message.format.apply(this, args); 64 | } 65 | // Get the nearby entities 66 | entities = map.getEntitiesWithinRadius(centerX, centerY, centerZ, 5); 67 | // Iterate through nearby entities, sending the message if 68 | // they can receive it. 69 | for(var i = 0; i < entities.length; i++) { 70 | if(entities[i].hasMixin(Game.EntityMixins.MessageRecipient)) { 71 | entities[i].receiveMessage(message); 72 | } 73 | } 74 | }, 75 | switchScreen: function(screen) { 76 | // If we had a screen before, notify it that we exited 77 | if (this._currentScreen !== null) { 78 | this._currentScreen.exit(); 79 | } 80 | // Clear the display 81 | this.getDisplay().clear(); 82 | // Update our current screen, notify it we entered 83 | // and then render it 84 | this._currentScreen = screen; 85 | if (!this._currentScreen !== null) { 86 | this._currentScreen.enter(); 87 | this.refresh(); 88 | } 89 | } 90 | }; 91 | 92 | window.onload = function() { 93 | // Check if rot.js can work on this browser 94 | if (!ROT.isSupported()) { 95 | alert("The rot.js library isn't supported by your browser."); 96 | } else { 97 | // Initialize the game 98 | Game.init(); 99 | // Add the container to our HTML page 100 | document.body.appendChild(Game.getDisplay().getContainer()); 101 | // Load the start screen 102 | Game.switchScreen(Game.Screen.startScreen); 103 | } 104 | } -------------------------------------------------------------------------------- /src/geometry.js: -------------------------------------------------------------------------------- 1 | Game.Geometry = { 2 | getLine: function(startX, startY, endX, endY) { 3 | var points = []; 4 | var dx = Math.abs(endX - startX); 5 | var dy = Math.abs(endY - startY); 6 | var sx = (startX < endX) ? 1 : -1; 7 | var sy = (startY < endY) ? 1 : -1; 8 | var err = dx - dy; 9 | var e2; 10 | 11 | while (true) { 12 | points.push({x: startX, y: startY}); 13 | if (startX == endX && startY == endY) { 14 | break; 15 | } 16 | e2 = err * 2; 17 | if (e2 > -dx) { 18 | err -= dy; 19 | startX += sx; 20 | } 21 | if (e2 < dx){ 22 | err += dx; 23 | startY += sy; 24 | } 25 | } 26 | 27 | return points; 28 | }, 29 | getCircle: function(centerX, centerY, radius) { 30 | var angle = 0, 31 | increment = 10 / radius, // should have an inverse relationship to radius 32 | points = [], 33 | repeatTries = 0, 34 | x, y; 35 | 36 | while(angle <= 360) { 37 | x = Math.round(centerX + radius * Math.cos(angle)); 38 | y = Math.round(centerY + radius * Math.sin(angle)); 39 | 40 | var key = x + "," + y; 41 | if(points.indexOf(key) < 0) 42 | points.push(key); 43 | else 44 | repeatTries++; 45 | 46 | angle += increment; 47 | } 48 | return points; 49 | }, 50 | getDistance: function(startX, startY, endX, endY) { 51 | // Math.pow(a, 2) + Math.pow(b, 2) = Math.pow(c, 2) 52 | // c = Math.sqrt(Math.pow(a, 2) + Math.pow(b, 2)) 53 | var horizontalDistance = startX - endX; 54 | var verticalDistance = startY - endY; 55 | return Math.sqrt(Math.pow(horizontalDistance, 2) + Math.pow(verticalDistance, 2)); 56 | } 57 | }; -------------------------------------------------------------------------------- /src/glyph-dynamic.js: -------------------------------------------------------------------------------- 1 | Game.DynamicGlyph = function(properties) { 2 | properties = properties || {}; 3 | // Call the glyph's construtor with our set of properties 4 | Game.Glyph.call(this, properties); 5 | // Instantiate any properties from the passed object 6 | this._name = properties['name'] || ''; 7 | // Create an object which will keep track what mixins we have 8 | // attached to this entity based on the name property 9 | this._attachedMixins = {}; 10 | // Create a similar object for groups 11 | this._attachedMixinGroups = {}; 12 | // Set up an object for listeners 13 | this._listeners = {}; 14 | // Setup the object's mixins 15 | var mixins = properties['mixins'] || []; 16 | for (var i = 0; i < mixins.length; i++) { 17 | // Copy over all properties from each mixin as long 18 | // as it's not the name or the init property. We 19 | // also make sure not to override a property that 20 | // already exists on the entity. 21 | for (var key in mixins[i]) { 22 | if (key != 'init' && key != 'name' && !this.hasOwnProperty(key)) { 23 | this[key] = mixins[i][key]; 24 | } 25 | } 26 | // Add the name of this mixin to our attached mixins 27 | this._attachedMixins[mixins[i].name] = true; 28 | // If a group name is present, add it 29 | if (mixins[i].groupName) { 30 | this._attachedMixinGroups[mixins[i].groupName] = true; 31 | } 32 | // Add all of our listeners 33 | if (mixins[i].listeners) { 34 | for (var key in mixins[i].listeners) { 35 | // If we don't already have a key for this event in our listeners array, add it. 36 | if (!this._listeners[key]) { 37 | this._listeners[key] = []; 38 | } 39 | // Add the listener. 40 | this._listeners[key].push(mixins[i].listeners[key]); 41 | } 42 | } 43 | // Finally call the init function if there is one 44 | if (mixins[i].init) { 45 | mixins[i].init.call(this, properties); 46 | } 47 | } 48 | }; 49 | // Make dynamic glyphs inherit all the functionality from glyphs 50 | Game.DynamicGlyph.extend(Game.Glyph); 51 | 52 | Game.DynamicGlyph.prototype.hasMixin = function(obj) { 53 | // Allow passing the mixin itself or the name / group name as a string 54 | if (typeof obj === 'object') { 55 | return this._attachedMixins[obj.name]; 56 | } else { 57 | return this._attachedMixins[obj] || this._attachedMixinGroups[obj]; 58 | } 59 | }; 60 | 61 | Game.DynamicGlyph.prototype.setName = function(name) { 62 | this._name = name; 63 | }; 64 | 65 | Game.DynamicGlyph.prototype.getName = function() { 66 | return this._name; 67 | }; 68 | Game.DynamicGlyph.prototype.details = function() { 69 | var details = []; 70 | var detailGroups = this.raiseEvent('details'); 71 | // Iterate through each return value, grabbing the details from the arrays. 72 | if (detailGroups) { 73 | for (var i = 0, l = detailGroups.length; i < l; i++) { 74 | if (detailGroups[i]) { 75 | for (var j = 0; j < detailGroups[i].length; j++) { 76 | details.push(detailGroups[i][j].key + ': ' + detailGroups[i][j].value); 77 | } 78 | } 79 | } 80 | } 81 | return details.join(', '); 82 | }; 83 | Game.DynamicGlyph.prototype.describe = function() { 84 | return this._name; 85 | }; 86 | Game.DynamicGlyph.prototype.describeA = function(capitalize) { 87 | // Optional parameter to capitalize the a/an. 88 | var prefixes = capitalize ? ['A', 'An'] : ['a', 'an']; 89 | var string = this.describe(); 90 | var firstLetter = string.charAt(0).toLowerCase(); 91 | // If word starts by a vowel, use an, else use a. Note that this is not perfect. 92 | var prefix = 'aeiou'.indexOf(firstLetter) >= 0 ? 1 : 0; 93 | 94 | return prefixes[prefix] + ' ' + string; 95 | }; 96 | Game.DynamicGlyph.prototype.describeThe = function(capitalize) { 97 | var prefix = capitalize ? 'The' : 'the'; 98 | return prefix + ' ' + this.describe(); 99 | }; 100 | 101 | Game.DynamicGlyph.prototype.raiseEvent = function(event) { 102 | // Make sure we have at least one listener, or else exit 103 | if (!this._listeners[event]) { 104 | return; 105 | } 106 | // Extract any arguments passed, removing the event name 107 | var args = Array.prototype.slice.call(arguments, 1); 108 | // Invoke each listener, with this entity as the context and the arguments 109 | var results = []; 110 | for (var i = 0; i < this._listeners[event].length; i++) { 111 | results.push(this._listeners[event][i].apply(this, args)); 112 | } 113 | return results; 114 | }; -------------------------------------------------------------------------------- /src/glyph.js: -------------------------------------------------------------------------------- 1 | // From http://www.codingcookies.com/2013/04/05/building-a-roguelike-in-javascript-part-3a/ 2 | // Base prototype for representing characters 3 | Game.Glyph = function(properties) { 4 | // Instantiate properties to default if they weren't passed 5 | properties = properties || {}; 6 | this._char = properties['character'] || ' '; 7 | this._foreground = properties['foreground'] || 'white'; 8 | this._background = properties['background'] || 'black'; 9 | }; 10 | 11 | // Create standard getters for glyphs 12 | Game.Glyph.prototype.getChar = function(){ 13 | return this._char; 14 | }; 15 | Game.Glyph.prototype.getBackground = function(){ 16 | return this._background; 17 | }; 18 | Game.Glyph.prototype.getForeground = function(){ 19 | return this._foreground; 20 | }; 21 | Game.Glyph.prototype.getRepresentation = function() { 22 | return '%c{' + this._foreground + '}%b{' + this._background + '}' + this._char + 23 | '%c{white}%b{black}'; 24 | }; -------------------------------------------------------------------------------- /src/input.js: -------------------------------------------------------------------------------- 1 | Game.Input = {}; 2 | 3 | Game.Input.controlMaps = {}; 4 | 5 | // Defining controlMaps like this should, theoretically, allow key bindings for each screen 6 | // to be re-configured on the fly; simply say: 7 | // 8 | // `Game.Input.controlMaps[screen][inputType][input] = someFunc.bind(this, params);` 9 | // 10 | // and ker-blamo. 11 | // These controlMaps could/should be defined from each screen? Maybe not 12 | Game.Input.controlMaps.playScreen = { 13 | keydown: { 14 | 'ArrowRight': Game.Commands.moveCommand.bind(this, -1, 0, 0), 15 | 'ArrowLeft': Game.Commands.moveCommand.bind(this, 1, 0, 0), 16 | 'ArrowDown': Game.Commands.moveCommand.bind(this, 0, -1, 0), 17 | 'ArrowUp': Game.Commands.moveCommand.bind(this, 0, 1, 0), 18 | '>': Game.Commands.moveCommand.bind(this, 0, 0, -1), 19 | '<': Game.Commands.moveCommand.bind(this, 0, 0, 1), 20 | 'Space': Game.Commands.showScreenCommand.bind(this, Game.Screen.actionScreen, Game.Screen.playScreen), 21 | 'i': Game.Commands.showItemScreenCommand.bind(this, Game.Screen.inventoryScreen, Game.Screen.playScreen, 'You are not carrying anything.'), 22 | 'd': Game.Commands.showItemScreenCommand.bind(this, Game.Screen.dropScreen, Game.Screen.playScreen, 'You have nothing to drop.'), 23 | 'e': Game.Commands.showItemScreenCommand.bind(this, Game.Screen.eatScreen, Game.Screen.playScreen, 'You have nothing to eat.'), 24 | 'w': Game.Commands.showItemScreenCommand.bind(this, Game.Screen.wieldScreen, Game.Screen.playScreen, 'You have nothing to wield.'), 25 | 'W': Game.Commands.showItemScreenCommand.bind(this, Game.Screen.wearScreen, Game.Screen.playScreen, 'You have nothing to wear.'), 26 | 'x': Game.Commands.showItemScreenCommand.bind(this, Game.Screen.examineScreen, Game.Screen.playScreen, 'You have nothing to examine.'), 27 | 't': Game.Commands.showItemScreenCommand.bind(this, Game.Screen.throwScreen, Game.Screen.playScreen, 'You have nothing to throw.'), 28 | ',': Game.Commands.showItemScreenCommand.bind(this, Game.Screen.pickupScreen, Game.Screen.playScreen, 'There is nothing here to pick up.', function(entity) { 29 | return entity.getMap().getItemsAt(entity.getX(), entity.getY(), entity.getZ()); 30 | }), 31 | ';': Game.Commands.showTargettingScreenCommand.bind(this, Game.Screen.lookScreen, Game.Screen.playScreen), 32 | '?': Game.Commands.showScreenCommand.bind(this, Game.Screen.helpScreen, Game.Screen.playScreen) 33 | } 34 | }; 35 | 36 | Game.Input.controlMaps.ItemListScreen = { 37 | keydown: { 38 | 'Escape': Game.Commands.removeSubScreenCommand.bind(this, Game.Screen.playScreen), 39 | 'Enter': Game.Commands.ItemScreenExecuteOkCommand.bind(this, Game.Screen.playScreen, 'Enter'), 40 | '0': Game.Commands.ItemScreenExecuteOkCommand.bind(this, Game.Screen.playScreen, "0"), 41 | "a": Game.Commands.ItemScreenExecuteOkCommand.bind(this, Game.Screen.playScreen, "a"), 42 | "b": Game.Commands.ItemScreenExecuteOkCommand.bind(this, Game.Screen.playScreen, "b"), 43 | "c": Game.Commands.ItemScreenExecuteOkCommand.bind(this, Game.Screen.playScreen, "c"), 44 | "d": Game.Commands.ItemScreenExecuteOkCommand.bind(this, Game.Screen.playScreen, "d"), 45 | "e": Game.Commands.ItemScreenExecuteOkCommand.bind(this, Game.Screen.playScreen, "e"), 46 | "f": Game.Commands.ItemScreenExecuteOkCommand.bind(this, Game.Screen.playScreen, "f"), 47 | "g": Game.Commands.ItemScreenExecuteOkCommand.bind(this, Game.Screen.playScreen, "g"), 48 | "h": Game.Commands.ItemScreenExecuteOkCommand.bind(this, Game.Screen.playScreen, "h"), 49 | "i": Game.Commands.ItemScreenExecuteOkCommand.bind(this, Game.Screen.playScreen, "i"), 50 | "j": Game.Commands.ItemScreenExecuteOkCommand.bind(this, Game.Screen.playScreen, "j"), 51 | "k": Game.Commands.ItemScreenExecuteOkCommand.bind(this, Game.Screen.playScreen, "k"), 52 | "l": Game.Commands.ItemScreenExecuteOkCommand.bind(this, Game.Screen.playScreen, "l"), 53 | "m": Game.Commands.ItemScreenExecuteOkCommand.bind(this, Game.Screen.playScreen, "m"), 54 | "n": Game.Commands.ItemScreenExecuteOkCommand.bind(this, Game.Screen.playScreen, "n"), 55 | "o": Game.Commands.ItemScreenExecuteOkCommand.bind(this, Game.Screen.playScreen, "o"), 56 | "p": Game.Commands.ItemScreenExecuteOkCommand.bind(this, Game.Screen.playScreen, "p"), 57 | "q": Game.Commands.ItemScreenExecuteOkCommand.bind(this, Game.Screen.playScreen, "q"), 58 | "r": Game.Commands.ItemScreenExecuteOkCommand.bind(this, Game.Screen.playScreen, "r"), 59 | "s": Game.Commands.ItemScreenExecuteOkCommand.bind(this, Game.Screen.playScreen, "s"), 60 | "t": Game.Commands.ItemScreenExecuteOkCommand.bind(this, Game.Screen.playScreen, "t"), 61 | "u": Game.Commands.ItemScreenExecuteOkCommand.bind(this, Game.Screen.playScreen, "u"), 62 | "v": Game.Commands.ItemScreenExecuteOkCommand.bind(this, Game.Screen.playScreen, "v"), 63 | "w": Game.Commands.ItemScreenExecuteOkCommand.bind(this, Game.Screen.playScreen, "w"), 64 | "x": Game.Commands.ItemScreenExecuteOkCommand.bind(this, Game.Screen.playScreen, "x"), 65 | "y": Game.Commands.ItemScreenExecuteOkCommand.bind(this, Game.Screen.playScreen, "y"), 66 | "z": Game.Commands.ItemScreenExecuteOkCommand.bind(this, Game.Screen.playScreen, "z") 67 | } 68 | }; 69 | 70 | Game.Input.controlMaps.TargetBasedScreen = { 71 | keydown: { 72 | "ArrowRight": Game.Commands.moveCursorCommand.bind(this, Game.Screen.playScreen, 1, 0), 73 | "ArrowLeft": Game.Commands.moveCursorCommand.bind(this, Game.Screen.playScreen, -1, 0), 74 | "ArrowUp": Game.Commands.moveCursorCommand.bind(this, Game.Screen.playScreen, 0, -1), 75 | "ArrowDown": Game.Commands.moveCursorCommand.bind(this, Game.Screen.playScreen, 0, 1), 76 | "Enter": Game.Commands.TargetBasedScreenOkCommand.bind(this, Game.Screen.playScreen), 77 | "Escape": Game.Commands.removeSubScreenCommand.bind(this, Game.Screen.playScreen) 78 | } 79 | }; 80 | 81 | Game.Input.controlMaps.MenuScreen = { 82 | keydown: { 83 | "ArrowUp": Game.Commands.moveMenuIndexCommand.bind(this, Game.Screen.playScreen, -1), 84 | "ArrowDown": Game.Commands.moveMenuIndexCommand.bind(this, Game.Screen.playScreen, 1), 85 | "Enter": Game.Commands.MenuScreenOkCommand.bind(this, Game.Screen.playScreen), 86 | " ": Game.Commands.MenuScreenOkCommand.bind(this, Game.Screen.playScreen), 87 | "Escape": Game.Commands.removeSubScreenCommand.bind(this, Game.Screen.playScreen) 88 | } 89 | } 90 | 91 | // This function is meant to handle input data of all types 92 | Game.Input.handleInput = function(screen, inputType, inputData) { 93 | // Each keyMap object should contain a list of references to Commands with specific parameters 94 | // bound to them. These command functions will return a function that can be executed later, 95 | // by passing in a specific entity to the function returned from `handleInput` 96 | // TODO: inputData.key is only good for key events. need a way to abstract out data depending on event type 97 | if(Game.Input.controlMaps[screen][inputType][inputData.key]) 98 | return Game.Input.controlMaps[screen][inputType][inputData.key](); 99 | else { 100 | return Game.Commands.nullCommand(); 101 | } 102 | }; 103 | -------------------------------------------------------------------------------- /src/item-mixins.js: -------------------------------------------------------------------------------- 1 | Game.ItemMixins = {}; 2 | 3 | Game.ItemMixins.Edible = { 4 | name: 'Edible', 5 | init: function(template) { 6 | // Number of points to add to hunger 7 | this._foodValue = template['foodValue'] || 5; 8 | // Number of times the item can be consumed 9 | this._maxConsumptions = template['consumptions'] || 1; 10 | this._remainingConsumptions = this._maxConsumptions; 11 | }, 12 | eat: function(entity) { 13 | if (entity.hasMixin('FoodConsumer')) { 14 | if (this.hasRemainingConsumptions()) { 15 | entity.modifyFullnessBy(this._foodValue); 16 | this._remainingConsumptions--; 17 | } 18 | } 19 | }, 20 | hasRemainingConsumptions: function() { 21 | return this._remainingConsumptions > 0; 22 | }, 23 | describe: function() { 24 | if (this._maxConsumptions != this._remainingConsumptions) { 25 | return 'partly eaten ' + Game.Item.prototype.describe.call(this); 26 | } else { 27 | return this._name; 28 | } 29 | }, 30 | listeners: { 31 | 'details': function() { 32 | return [{key: 'food', value: this._foodValue}]; 33 | } 34 | } 35 | }; 36 | Game.ItemMixins.Equippable = { 37 | name: 'Equippable', 38 | init: function(template) { 39 | this._attackValue = template['attackValue'] || 0; 40 | this._defenseValue = template['defenseValue'] || 0; 41 | this._wieldable = template['wieldable'] || false; 42 | this._wearable = template['wearable'] || false; 43 | this._wielded = false; 44 | this._worn = false; 45 | }, 46 | getAttackValue: function() { 47 | return this._attackValue; 48 | }, 49 | getDefenseValue: function() { 50 | return this._defenseValue; 51 | }, 52 | isWieldable: function() { 53 | return this._wieldable; 54 | }, 55 | isWielded: function() { 56 | return this._wielded; 57 | }, 58 | wield: function() { 59 | this._wielded = true; 60 | }, 61 | unwield: function() { 62 | this._wield = false; 63 | }, 64 | isWearable: function() { 65 | return this._wearable; 66 | }, 67 | isWorn: function() { 68 | return this._worn; 69 | }, 70 | wear: function() { 71 | this._worn = true; 72 | }, 73 | takeOff: function() { 74 | this._worn = false; 75 | }, 76 | listeners: { 77 | 'details': function() { 78 | var results = []; 79 | if (this._wieldable) { 80 | results.push({key: 'attack', value: this.getAttackValue()}); 81 | } 82 | if (this._wearable) { 83 | results.push({key: 'defense', value: this.getDefenseValue()}); 84 | } 85 | return results; 86 | } 87 | } 88 | }; 89 | Game.ItemMixins.Stackable = { 90 | name: 'Stackable', 91 | init: function(template) { 92 | this._stackable = template['stackable'] || false; 93 | this._count = template['count'] || 1; 94 | }, 95 | amount: function() { 96 | return this._count; 97 | }, 98 | addToStack: function(amount) { 99 | if(amount) { 100 | this._count += amount; 101 | } else { 102 | this._count++; 103 | } 104 | }, 105 | isStackable: function() { 106 | return this._stackable; 107 | }, 108 | removeFromStack: function(amount) { 109 | if(amount) { 110 | this._count -= amount; 111 | } else { 112 | this._count--; 113 | } 114 | } 115 | }; 116 | Game.ItemMixins.Throwable = { 117 | name: 'Throwable', 118 | init: function(template) { 119 | this._throwable = template['throwable'] || false; 120 | this._attackValue = template['attackValue'] || 1; 121 | }, 122 | getAttackValue: function() { 123 | return this._attackValue; 124 | }, 125 | isThrowable: function() { 126 | return this._throwable; 127 | }, 128 | listeners: { 129 | 'details': function() { 130 | var results = []; 131 | if (this._throwable) { 132 | results.push({key: 'attack', value: this.getAttackValue()}); 133 | } 134 | return results; 135 | } 136 | } 137 | }; 138 | 139 | // Adding an item to a container removes it from the entity. 140 | // Removing an item from a container adds it to the entity. 141 | Game.ItemMixins.Container = { 142 | name: 'Container', 143 | init: function(template) { 144 | this._items = []; 145 | }, 146 | getItems: function() { 147 | return this._items; 148 | }, 149 | getItem: function(i) { 150 | return this._items[i]; 151 | }, 152 | addItem: function(entity, index, amount) { 153 | debugger; 154 | if(!entity.hasMixin('InventoryHolder') && !entity.hasMixin('Container')) { 155 | return false; 156 | } 157 | var item = entity.getItem(index); 158 | this._items.push(item); 159 | entity.removeItem(index, amount); 160 | 161 | if(entity.hasMixin('MessageRecipient')) 162 | Game.sendMessage(entity, "You place %s into %s", [item.describeThe(), this.describeThe()]); 163 | 164 | }, 165 | removeItem: function(entity, index, amount) { 166 | debugger; 167 | if(!entity.hasMixin('InventoryHolder') && !entity.hasMixin('Container')) { 168 | return false; 169 | } 170 | var item = this.getItem(index); 171 | entity.addItem(item); 172 | this._items.splice(index, 1); 173 | 174 | if(entity.hasMixin('MessageRecipient')) 175 | Game.sendMessage(entity, "You remove %s from %s", [item.describeThe(), this.describeThe()]); 176 | }, 177 | listeners: { 178 | 'action': function(actionTaker) { 179 | var actions = {}; 180 | var actionName = "Open %s".format(this.describeThe()); 181 | 182 | // array of functions to execute. For each sub-array, 183 | // first value is the action function, 184 | // second value are the args, 185 | // third (optional) value is the 'this' context to use 186 | actions[actionName] = [ 187 | [Game.Screen.containerScreen.setup, [actionTaker, actionTaker.getItems(), this, this.getItems()], Game.Screen.containerScreen], 188 | [Game.Screen.playScreen.setSubScreen, [Game.Screen.containerScreen], Game.Screen.playScreen] 189 | ]; 190 | return actions; 191 | } 192 | } 193 | }; -------------------------------------------------------------------------------- /src/item.js: -------------------------------------------------------------------------------- 1 | Game.Item = function(properties) { 2 | properties = properties || {}; 3 | // Call the glyph's constructor with our set of properties 4 | Game.DynamicGlyph.call(this, properties); 5 | // Instantiate any properties from the passed object 6 | this._name = properties['name'] || ''; 7 | }; 8 | // Make items inherit all the functionality from glyphs 9 | Game.Item.extend(Game.DynamicGlyph); -------------------------------------------------------------------------------- /src/loader.js: -------------------------------------------------------------------------------- 1 | // Game.Loader is a simple utility to track modules as they load (for the purpose of a load screen or some such). 2 | // The loader can be initialized with an arbitrary list of module names. Additional modules 3 | // can be `register`ed to the loader instance, as well as sub-modules. 4 | Game.Loader = function(modules) { 5 | this.modules = {}; 6 | for (var i = 0; i < modules.length; i++) { 7 | this.modules[modules[i]] = { 8 | submodules: {}, 9 | progress: 0 10 | }; 11 | } 12 | this.totalProgress = 0; 13 | this.currentlyLoading = []; 14 | }; 15 | Game.Loader.prototype.getProgress = function() { 16 | return this.totalProgress; 17 | }; 18 | Game.Loader.prototype.registerModule = function(module, submodule) { 19 | if(module in this.modules === false) { 20 | this.modules[module] = { 21 | submodules: {}, 22 | progress: 0 23 | }; 24 | } else if(submodule in this.modules[module].submodules === false) { 25 | this.modules[module].submodules[submodule] = { 26 | progress: 0 27 | }; 28 | } 29 | }; 30 | Game.Loader.prototype.startModule = function(module) { 31 | if(this.currentlyLoading.indexOf(module) === -1) 32 | this.currentlyLoading.push(module); 33 | }; 34 | Game.Loader.prototype.startSubmodule = function(module, submodule) { 35 | if(this.currentlyLoading.indexOf(module) === -1) 36 | throw new Error("'" + module + "' is not currently loading."); 37 | if(this.currentlyLoading.indexOf(submodule) === -1) 38 | this.currentlyLoading.push(submodule); 39 | }; 40 | Game.Loader.prototype.finishModule = function(module) { 41 | if(module in this.modules === false) 42 | throw new Error("'" + module + "' is not a registered module"); 43 | 44 | // Make progress 100 45 | this.modules[module].progress = 100; 46 | 47 | // Remove item from currently loading list 48 | var index = this.currentlyLoading.indexOf(module); 49 | this.currentlyLoading.splice(index, 1); 50 | 51 | this._updateProgress(); 52 | }; 53 | Game.Loader.prototype.finishSubmodule = function(module, submodule) { 54 | if(this.currentlyLoading.indexOf(module) === -1) 55 | throw new Error("'" + module + "' is not currently loading."); 56 | else if(this.currentlyLoading.indexOf(submodule) === -1) 57 | throw new Error("'" + submodule + "' is not currently loading"); 58 | else if(module in this.modules === false) 59 | throw new Error("'" + module + "' is not a registered module"); 60 | else if(submodule in this.modules[module].submodules === false) 61 | throw new Error("'" + submodule + "' is not a registered submodule of '" + module + "'"); 62 | 63 | this.modules[module].submodules[submodule].progress = 100; 64 | var index = this.currentlyLoading.indexOf(submodule); 65 | this.currentlyLoading.splice(index, 1); 66 | 67 | this._updateProgress(); 68 | }; 69 | Game.Loader.prototype.updateModule = function(module, amount) { 70 | if(module in this.modules === false) 71 | throw new Error("'" + module + "' is not a registered module"); 72 | 73 | this.modules[module].progress = amount; 74 | 75 | this._updateProgress(); 76 | }; 77 | Game.Loader.prototype.updateSubmodule = function(module, submodule, amount) { 78 | if(module in this.modules === false) 79 | throw new Error("'" + module + "' is not a registered module"); 80 | else if(submodule in this.modules[module].submodules === false) 81 | throw new Error("'" + submodule + "' is not a submodule of '" + module + "'"); 82 | 83 | this.modules[module].submodules[submodule].progress = amount; 84 | 85 | // Update module progress as a function of the submodule's progress 86 | var numSubmodules = Object.keys(this.modules[module].submodules).length, 87 | maxProgress = numSubmodules * 100, 88 | currentProgress = 0; 89 | 90 | for(var sm in this.modules[module].submodules) 91 | currentProgress += this.modules[module].submodules[sm].progress; 92 | 93 | this.modules[module].progress = (currentProgress / maxProgress) * 100; 94 | 95 | this._updateProgress(); 96 | }; 97 | Game.Loader.prototype._updateProgress = function() { 98 | var numModules = Object.keys(this.modules).length, 99 | maxProgress = numModules * 100, 100 | currentProgress = 0, 101 | submodules, moduleMaxProgress, moduleProgress; 102 | 103 | for(var module in this.modules) { 104 | submodules = Object.keys(this.modules[module].submodules); 105 | moduleMaxProgress = submodules.length * 100; 106 | moduleProgress = 0; 107 | if(submodules.length) { 108 | for(var submodule in this.modules[module].submodules) { 109 | moduleProgress += this.modules[module].submodules[submodule].progress 110 | } 111 | 112 | this.modules[module].progress = (moduleProgress / moduleMaxProgress) * 100; 113 | } 114 | 115 | currentProgress += this.modules[module].progress; 116 | } 117 | 118 | for(var module in this.modules) 119 | currentProgress += this.modules[module].progress; 120 | 121 | this.totalProgress = (currentProgress / maxProgress) * 100; 122 | }; 123 | -------------------------------------------------------------------------------- /src/map.js: -------------------------------------------------------------------------------- 1 | // @size should be square number of lots for a city. 2 | Game.Map = function(width, height, depth, player) { 3 | // Used for drawing to various displays 4 | this._tiles = this._generateTiles(width, height, depth); 5 | 6 | // Cache dimensions 7 | this._depth = this._tiles.length; 8 | this._width = this._tiles[0].length; 9 | this._height = this._tiles[0][0].length; 10 | 11 | // Setup the field of visions 12 | this._fov = []; 13 | this.setupFov(); 14 | 15 | // Create a table which will hold the items 16 | this._items = {}; 17 | 18 | // Create the engine and scheduler 19 | this._scheduler = new ROT.Scheduler.Speed(); 20 | this._engine = new ROT.Engine(this._scheduler); 21 | 22 | // Setup the explored array 23 | this._explored = new Array(this._depth); 24 | this._setupExploredArray(); 25 | 26 | // Create a table which will hold the entities 27 | this._entities = {}; 28 | 29 | // Add monsters here 30 | for (var z = 0; z < this._depth; z++) 31 | for (var i = 0; i < 10; i++) 32 | this.addEntityAtRandomPosition(Game.EntityRepository.createRandom(), z); 33 | 34 | // Add the Player 35 | this.addEntityAtRandomPosition(player, 0); 36 | }; 37 | 38 | // Standard getters 39 | Game.Map.prototype.getDepth = function() { 40 | return this._depth; 41 | }; 42 | Game.Map.prototype.getWidth = function() { 43 | return this._width; 44 | }; 45 | Game.Map.prototype.getHeight = function() { 46 | return this._height; 47 | }; 48 | Game.Map.prototype.getScheduler = function() { 49 | return this._scheduler; 50 | }; 51 | Game.Map.prototype.getEngine = function() { 52 | return this._engine; 53 | }; 54 | Game.Map.prototype.getEntities = function() { 55 | return this._entities; 56 | }; 57 | Game.Map.prototype.getPlayer = function() { 58 | return this._player; 59 | }; 60 | 61 | Game.Map.prototype._generateTiles = function(width, height, depth) { 62 | var tiles = new Array(depth); 63 | var dungeon = new ROT.Map.Digger(width, height); 64 | 65 | // Instantiate the arrays to be multi-dimension 66 | for (var z = 0; z < depth; z++) { 67 | // Create a new cave at each level 68 | dungeon.create(function(x, y, wall) { 69 | if(!tiles[z]) 70 | tiles[z] = new Array(width); 71 | if(!tiles[z][x]) 72 | tiles[z][x] = new Array(height); 73 | 74 | tiles[z][x][y] = wall ? Game.TileRepository.create('wall') : Game.TileRepository.create('floor'); 75 | }); 76 | } 77 | 78 | // Place stairs 79 | // Note, this does not actually ensure that stairs will be placed 80 | for(var level = 0; level < depth - 1; level++) { 81 | var floorX = null, 82 | floorY = null, 83 | tries = 0; 84 | do { 85 | floorX = Math.floor(Math.random() * width); 86 | floorY = Math.floor(Math.random() * height); 87 | tries++; 88 | } while((tiles[level][floorX][floorY].describe() !== 'floor' || 89 | tiles[level + 1][floorX][floorY].describe() !== 'floor') && 90 | tries < 1000); 91 | 92 | 93 | if(tries < 1000 && floorX !== null && floorY !== null) { 94 | tiles[level][floorX][floorY] = Game.TileRepository.create('stairsDown'); 95 | tiles[level + 1][floorX][floorY] = Game.TileRepository.create('stairsUp'); 96 | } 97 | } 98 | 99 | return tiles; 100 | }; 101 | 102 | // For just adding actors to the scheduler 103 | Game.Map.prototype.schedule = function(actor) { 104 | if('act' in actor) { 105 | this._scheduler.add(actor, true); 106 | } 107 | if('_map' in actor) { 108 | actor._map = this; 109 | } 110 | }; 111 | 112 | // Entities 113 | Game.Map.prototype.addEntity = function(entity) { 114 | // Set the entity's map 115 | entity.setMap(this); 116 | 117 | // Add the entity to the map's list of entities 118 | this.updateEntityPosition(entity); 119 | 120 | // Check to see if the entity is an actor 121 | // If so, add them to the scheduler 122 | if(entity.hasMixin('Actor')) { 123 | this._scheduler.add(entity, true); 124 | } 125 | 126 | // If the entity is the player, set the player. 127 | if (entity.hasMixin(Game.EntityMixins.PlayerActor)) { 128 | this._player = entity; 129 | } 130 | }; 131 | Game.Map.prototype.addEntityAtRandomPosition = function(entity, z) { 132 | var position = this.getRandomFloorPosition(z); 133 | entity.setX(position.x); 134 | entity.setY(position.y); 135 | entity.setZ(position.z); 136 | this.addEntity(entity); 137 | }; 138 | Game.Map.prototype.getEntityAt = function(x, y, z) { 139 | // Get the entity based on position key 140 | return this._entities[x + ',' + y + ',' + z]; 141 | }; 142 | Game.Map.prototype.getEntitiesWithinRadius = function(centerX, centerY, centerZ, radius) { 143 | var results = []; 144 | // Determine the bounds... 145 | var leftX = centerX - radius; 146 | var rightX = centerX + radius; 147 | var topY = centerY - radius; 148 | var bottomY = centerY + radius; 149 | for (var key in this._entities) { 150 | var entity = this._entities[key]; 151 | if (entity.getX() >= leftX && 152 | entity.getX() <= rightX && 153 | entity.getY() >= topY && 154 | entity.getY() <= bottomY && 155 | entity.getZ() == centerZ) { 156 | results.push(entity); 157 | } 158 | } 159 | return results; 160 | }; 161 | Game.Map.prototype.removeEntity = function(entity) { 162 | // Find the entity in the list of entities if it is present 163 | var key = entity.getX() + ',' + entity.getY() + ',' + entity.getZ(); 164 | if(this._entities[key] == entity) { 165 | delete this._entities[key]; 166 | } 167 | 168 | // If the entity is an actor, remove them from the scheduler 169 | if (entity.hasMixin('Actor')) { 170 | this._scheduler.remove(entity); 171 | } 172 | 173 | // If the entity is the player, update the player field. 174 | if (entity.hasMixin(Game.EntityMixins.PlayerActor)) { 175 | this._player = undefined; 176 | } 177 | }; 178 | Game.Map.prototype.updateEntityPosition = function(entity, oldX, oldY, oldZ) { 179 | // Delete the old key if it is the same entity and we have old positons 180 | if(typeof oldX === 'number') { 181 | var oldKey = oldX + "," + oldY + "," + oldZ; 182 | if(this._entities[oldKey] == entity) { 183 | delete this._entities[oldKey]; 184 | } 185 | } 186 | 187 | // Make sure the entity's position is within bounds 188 | if (entity.getX() < 0 || entity.getX() >= this._width || 189 | entity.getY() < 0 || entity.getY() >= this._height || 190 | entity.getZ() < 0 || entity.getZ() >= this._depth) { 191 | throw new Error("Entity's position is out of bounds."); 192 | } 193 | 194 | // Sanity check to make sure there is no entity at the new position 195 | var key = entity.getX() + "," + entity.getY() + "," + entity.getZ(); 196 | if (this._entities[key]) { 197 | throw new Error('Tried to add an entity at an occupied position.'); 198 | } 199 | 200 | // Add the entity to the table of entities 201 | this._entities[key] = entity; 202 | }; 203 | 204 | // Floors 205 | Game.Map.prototype.isEmptyFloor = function(x, y, z) { 206 | // Check if the tile is floor and also has no entity 207 | return this.getTile(x, y, z).describe() == 'floor' && !this.getEntityAt(x, y, z); 208 | }; 209 | Game.Map.prototype.getRandomFloorPosition = function(z) { 210 | var x, y; 211 | do { 212 | x = Math.floor(Math.random() * this._width); 213 | y = Math.floor(Math.random() * this._height); 214 | } while(!this.isEmptyFloor(x, y, z)); 215 | return {x: x, y: y, z: z}; 216 | }; 217 | 218 | // Tiles 219 | // Gets the tile for a given coordinate set 220 | Game.Map.prototype.getTile = function(x, y, z) { 221 | // Make sure we are inside the bounds. 222 | //If we aren't, return null tile. 223 | if (x < 0 || x >= this._width || y < 0 || y >= this._height || z < 0 || z >= this._depth) { 224 | return Game.TileRepository.create('null'); 225 | } else { 226 | return this._tiles[z][x][y] || Game.TileRepository.create('null'); 227 | } 228 | }; 229 | 230 | // FOV 231 | Game.Map.prototype.setupFov = function() { 232 | // Keep this in 'map' variable so that we don't lose it. 233 | var map = this; 234 | // Iterate through each depth level, setting up the field of vision 235 | for (var z = 0; z < this._depth; z++) { 236 | // We have to put the following code in it's own scope to prevent the 237 | // depth variable from being hoisted out of the loop. 238 | (function() { 239 | // For each depth, we need to create a callback which figures out 240 | // if light can pass through a given tile. 241 | var depth = z; 242 | map._fov.push(new ROT.FOV.PreciseShadowcasting(function(x, y) { 243 | return !map.getTile(x, y, depth).isBlockingLight(); 244 | })); 245 | })(); 246 | } 247 | }; 248 | Game.Map.prototype.getFov = function(depth) { 249 | return this._fov[depth]; 250 | }; 251 | 252 | // Explored Areas 253 | Game.Map.prototype._setupExploredArray = function() { 254 | for (var z = 0; z < this._depth; z++) { 255 | this._explored[z] = new Array(this._width); 256 | for (var x = 0; x < this._width; x++) { 257 | this._explored[z][x] = new Array(this._height); 258 | for (var y = 0; y < this._height; y++) { 259 | this._explored[z][x][y] = false; 260 | } 261 | } 262 | } 263 | }; 264 | Game.Map.prototype.setExplored = function(x, y, z, state) { 265 | // Only update if the tile is within bounds 266 | if (this.getTile(x, y, z).describe() !== 'null') { 267 | this._explored[z][x][y] = state; 268 | } 269 | }; 270 | Game.Map.prototype.isExplored = function(x, y, z) { 271 | // Only return the value if within bounds 272 | if (this.getTile(x, y, z).describe() !== 'null') { 273 | return this._explored[z][x][y]; 274 | } else { 275 | return false; 276 | } 277 | }; 278 | 279 | // Items - TODO: move this? 280 | Game.Map.prototype.getItemsAt = function(x, y, z) { 281 | return this._items[x + ',' + y + ',' + z]; 282 | }; 283 | 284 | Game.Map.prototype.setItemsAt = function(x, y, z, items) { 285 | // If our items array is empty, then delete the key from the table. 286 | var key = x + ',' + y + ',' + z; 287 | if (items.length === 0) { 288 | if (this._items[key]) { 289 | delete this._items[key]; 290 | } 291 | } else { 292 | // Simply update the items at that key 293 | this._items[key] = items; 294 | } 295 | }; 296 | 297 | Game.Map.prototype.addItem = function(x, y, z, item) { 298 | // If we already have items at that position, simply append the item to the list of items. 299 | var key = x + ',' + y + ',' + z; 300 | if (this._items[key]) { 301 | this._items[key].push(item); 302 | } else { 303 | this._items[key] = [item]; 304 | } 305 | }; 306 | 307 | Game.Map.prototype.addItemAtRandomPosition = function(item, z) { 308 | var position = this.getRandomFloorPosition(z); 309 | this.addItem(position.x, position.y, position.z, item); 310 | }; -------------------------------------------------------------------------------- /src/palette.js: -------------------------------------------------------------------------------- 1 | Game.Palette = { 2 | blue: 'blue', 3 | green: 'green', 4 | grey: 'grey', 5 | pink: 'pink', 6 | darkgrey: 'darkgrey', 7 | }; -------------------------------------------------------------------------------- /src/repository.js: -------------------------------------------------------------------------------- 1 | Game.Repository = function(name, ctor) { 2 | this._name = name; 3 | this._templates = {}; 4 | this._randomTemplates = {}; 5 | this._ctor = ctor; // ctor = 'constructor' 6 | }; 7 | 8 | Game.Repository.prototype.getTemplate = function(name) { 9 | return this._templates[name]; 10 | }; 11 | 12 | Game.Repository.prototype.getTemplates = function() { 13 | return this._templates; 14 | }; 15 | 16 | // Define a new named template. 17 | Game.Repository.prototype.define = function(name, template, options) { 18 | this._templates[name] = template; 19 | // Apply any options 20 | var disableRandomCreation = options && options['disableRandomCreation']; 21 | if (!disableRandomCreation) { 22 | this._randomTemplates[name] = template; 23 | } 24 | }; 25 | 26 | // Create an object based on a template. 27 | Game.Repository.prototype.create = function(name, extraProperties) { 28 | if(!this._templates[name]) { 29 | throw new Error("No template named '" + name + "' in repository '" + this._name + "'"); 30 | } 31 | // Copy the template 32 | var template = Object.create(this._templates[name]); 33 | // Apply any extra properties 34 | if(extraProperties) { 35 | for (var key in extraProperties) { 36 | // If a template has a property like 37 | // {random: true, values: ['val1', 'val2', 'val3']} 38 | // then create the element with a random value 39 | if( 40 | extraProperties[key]['random'] && 41 | extraProperties[key]['random'] === true && 42 | extraProperties[key]['values'] && 43 | extraProperties[key]['values'].constructor === Array 44 | ) { 45 | template[key] = extraProperties[key]['values'].random(); 46 | } else { 47 | template[key] = extraProperties[key]; 48 | } 49 | } 50 | } 51 | // Create the object, passing the template as an argument 52 | return new this._ctor(template); 53 | }; 54 | 55 | // Create an object based on a random template 56 | Game.Repository.prototype.createRandom = function() { 57 | // Pick a random key and create an object based off of it. 58 | return this.create(Object.keys(this._randomTemplates).random()); 59 | }; 60 | 61 | // Cycle through all the templates. If a template has a function that's name 62 | // matches the criteria string, it will execute that function to determine whether 63 | // or not to create the current template. If none are found, return false. 64 | Game.Repository.prototype.createIf = function(criteria) { 65 | var names = []; 66 | for (var name in this._templates) { 67 | names.push(name); 68 | }; 69 | var randomized = names.randomize(); 70 | 71 | // Loop through a randomized array of templates... 72 | for(var i = 0; i < randomized.length; i++) { 73 | // Create the object 74 | var temp = this.create(randomized[i]); 75 | 76 | // Check to see if the temp object has the function 77 | // that's name matches the criteria string 78 | if(temp[criteria]) { 79 | // Get any additional arguments passed to this function 80 | var args = Array.prototype.slice.call(arguments, 1) 81 | 82 | // Execute the criteria function 83 | var create = temp[criteria].apply(temp, args); 84 | if(create) { 85 | // If it passes, return the temp object 86 | return temp; 87 | } else { 88 | continue; 89 | } 90 | } 91 | } 92 | return false; 93 | }; -------------------------------------------------------------------------------- /src/screen.js: -------------------------------------------------------------------------------- 1 | Game.Screen = {}; 2 | 3 | Game.Screen.basicScreen = function(properties) { 4 | var requiredMethods = [ 5 | 'enter', 6 | 'exit', 7 | 'render', 8 | 'handleInput' 9 | ]; 10 | 11 | // Make sure they have the required methods... 12 | for(var i = 0; i < requiredMethods.length; i++) { 13 | var method = requiredMethods[i]; 14 | if(properties[method] === undefined) 15 | throw new Error("'" + method + "' is a missing from your properties list and is required for this type of screen"); 16 | } 17 | 18 | // Set properties for the screen 19 | if(properties) { 20 | for(var p in properties) { 21 | if(!this[p]) { 22 | this[p] = properties[p]; 23 | } 24 | } 25 | } 26 | }; 27 | 28 | // Item Listing 29 | Game.Screen.ItemListScreen = function(template) { 30 | // Set up based on the template 31 | this._caption = template['caption']; 32 | this._okFunction = template['ok']; 33 | // By default, we use the identity function 34 | this._isAcceptableFunction = template['isAcceptable'] || function(x) { 35 | return x; 36 | }; 37 | 38 | // Can the user select items at all? 39 | this._canSelectItem = template['canSelect']; 40 | 41 | // Can they select multiple items? 42 | this._canSelectMultipleItems = template['canSelectMultipleItems']; 43 | 44 | // Whether a 'no item' option should appear. 45 | this._hasNoItemOption = template['hasNoItemOption']; 46 | }; 47 | Game.Screen.ItemListScreen.prototype.setup = function(player, items) { 48 | this._player = player; 49 | // Should be called before switching to the screen. 50 | var count = 0; 51 | // Iterate over each item, keeping only the aceptable ones and counting the number of acceptable items. 52 | var that = this; 53 | this._items = items.map(function(item) { 54 | // Transform the item into null if it's not acceptable 55 | if (that._isAcceptableFunction(item)) { 56 | count++; 57 | return item; 58 | } else { 59 | return null; 60 | } 61 | }); 62 | 63 | // Clean set of selected indices 64 | this._selectedIndices = {}; 65 | return count; 66 | }; 67 | Game.Screen.ItemListScreen.prototype.render = function(display) { 68 | var letters = 'abcdefghijklmnopqrstuvwxyz'; 69 | // Render the no item row if enabled 70 | if (this._hasNoItemOption) { 71 | display.drawText(0, 1, '0 - no item'); 72 | } 73 | // Render the caption in the top row 74 | display.drawText(0, 0, this._caption); 75 | var row = 0; 76 | for(var i = 0; i < this._items.length; i++) { 77 | // If we have an item, we want to render it 78 | if(this._items[i]) { 79 | // Get the letter corresponding to the item's index 80 | var letter = letters.substring(i, i + 1); 81 | 82 | // If the item is selected, show a +, otherwise show a dash, then the item's name 83 | var selectionState = (this._canSelectItem && this._canSelectMultipleItems && this._selectedIndices[i]) ? '+' : '-'; 84 | 85 | // If the item is stackable, show the number we are currently holding 86 | var stack = this._items[i].hasMixin('Stackable') ? ' (' + this._items[i].amount() + ')' : ''; 87 | 88 | // Render at the correct row and add 2 89 | display.drawText(0, 2 + row, letter + ' ' + selectionState + ' ' + this._items[i].describe() + stack); 90 | row++; 91 | } 92 | } 93 | }; 94 | Game.Screen.ItemListScreen.prototype.executeOkFunction = function() { 95 | // Gather the selected items. 96 | var selectedItems = {}; 97 | for (var key in this._selectedIndices) { 98 | selectedItems[key] = this._items[key]; 99 | } 100 | 101 | // Switch back to play screen 102 | Game.Screen.playScreen.setSubScreen(undefined); 103 | 104 | // Return the result of the okFunction 105 | return this._okFunction ? this._okFunction(selectedItems) : false; 106 | }; 107 | Game.Screen.ItemListScreen.prototype.handleInput = function(inputType, inputData) { 108 | var command = Game.Input.handleInput("ItemListScreen", inputType, inputData); 109 | 110 | // Execute the command, and caputure return value 111 | var unlock = command ? command() : false; 112 | 113 | // If the return value is true, unlock the engine (player turn over) 114 | if(unlock) 115 | this._player.getMap().getEngine().unlock(); 116 | else 117 | Game.refresh(); 118 | }; 119 | 120 | // Targeting Screen 121 | Game.Screen.TargetBasedScreen = function(template) { 122 | template = template || {}; 123 | // By default, our ok return does nothing and does not consume a turn. 124 | this._okFunction = template['okFunction'] || function(x, y) { 125 | return false; 126 | }; 127 | // The default caption function returns a description of the tiles or creatures. 128 | this._captionFunction = template['captionFunction'] || function(x, y) { 129 | var z = this._player.getZ(); 130 | var map = this._player.getMap(); 131 | // If the tile is explored, we can give a better caption 132 | if (map.isExplored(x, y, z)) { 133 | // If the tile isn't explored, we have to check if we can actually 134 | // see it before testing if there's an entity or item. 135 | if (this._visibleCells[x + ',' + y]) { 136 | var items = map.getItemsAt(x, y, z); 137 | // If we have items, we want to render the top most item 138 | if (items) { 139 | var item = items[items.length - 1]; 140 | return String.format('%s - %s (%s)', 141 | item.getRepresentation(), 142 | item.describeA(true), 143 | item.details()); 144 | // Else check if there's an entity 145 | } else if (map.getEntityAt(x, y, z)) { 146 | var entity = map.getEntityAt(x, y, z); 147 | return String.format('%s - %s (%s)', 148 | entity.getRepresentation(), 149 | entity.describeA(true), 150 | entity.details()); 151 | } 152 | } 153 | // If there was no entity/item or the tile wasn't visible, then use 154 | // the tile information. 155 | return String.format('%s - %s', 156 | map.getTile(x, y, z).getRepresentation(), 157 | map.getTile(x, y, z).getDescription()); 158 | 159 | } else { 160 | var nullTile = Game.TileRepository.create('null'); 161 | // If the tile is not explored, show the null tile description. 162 | return String.format('%s - %s', 163 | nullTile.getRepresentation(), 164 | nullTile.getDescription()); 165 | } 166 | }; 167 | }; 168 | Game.Screen.TargetBasedScreen.prototype.setup = function(player, startX, startY, offsetX, offsetY) { 169 | this._player = player; 170 | // Store original position. Subtract the offset so we don't always have to remove it. 171 | this._startX = startX - offsetX; 172 | this._startY = startY - offsetY; 173 | // Store current cursor position 174 | this._cursorX = this._startX; 175 | this._cursorY = this._startY; 176 | // Store map offsets 177 | this._offsetX = offsetX; 178 | this._offsetY = offsetY; 179 | // Cache the FOV 180 | var visibleCells = {}; 181 | this._player.getMap().getFov(this._player.getZ()).compute( 182 | this._player.getX(), this._player.getY(), 183 | this._player.getSightRadius(), 184 | function(x, y, radius, visibility) { 185 | visibleCells[x + "," + y] = true; 186 | }); 187 | this._visibleCells = visibleCells; 188 | }; 189 | Game.Screen.TargetBasedScreen.prototype.render = function(display) { 190 | Game.Screen.playScreen.renderTiles.call(Game.Screen.playScreen, display); 191 | 192 | // Draw a line from the start to the cursor. 193 | var points = Game.Geometry.getLine(this._startX, this._startY, this._cursorX, this._cursorY); 194 | 195 | // Render stars along the line. 196 | for (var i = 1, l = points.length; i < l; i++) { 197 | if(i == l - 1) { 198 | display.drawText(points[i].x, points[i].y, '%c{white}X'); 199 | } else { 200 | display.drawText(points[i].x, points[i].y, '%c{white}*'); 201 | } 202 | 203 | } 204 | 205 | // Render the caption at the bottom. 206 | display.drawText(0, Game.getScreenHeight() - 1, 207 | this._captionFunction(this._cursorX + this._offsetX, this._cursorY + this._offsetY)); 208 | }; 209 | Game.Screen.TargetBasedScreen.prototype.handleInput = function(inputType, inputData) { 210 | var command = Game.Input.handleInput("TargetBasedScreen", inputType, inputData); 211 | var unlock = command ? command() : false; 212 | 213 | // If the return value is true, unlock the engine (player turn over) 214 | if(unlock) 215 | this._player.getMap().getEngine().unlock(); 216 | else 217 | Game.refresh(); 218 | }; 219 | Game.Screen.TargetBasedScreen.prototype.moveCursor = function(dx, dy) { 220 | // Make sure we stay within bounds. 221 | this._cursorX = Math.max(0, Math.min(this._cursorX + dx, Game.getScreenWidth())); 222 | // We have to save the last line for the caption. 223 | this._cursorY = Math.max(0, Math.min(this._cursorY + dy, Game.getScreenHeight() - 1)); 224 | }; 225 | Game.Screen.TargetBasedScreen.prototype.executeOkFunction = function() { 226 | if(this._okFunction) 227 | return this._okFunction(this._cursorX + this._offsetX, this._cursorY + this._offsetY); 228 | else 229 | return false; 230 | }; 231 | 232 | // Menu screens 233 | Game.Screen.MenuScreen = function(template) { 234 | template = template || {}; 235 | 236 | this._player = null; 237 | 238 | // Display settings 239 | this._caption = template['caption'] || 'Menu'; 240 | this._outerPadding = template['outerPadding'] || 4; 241 | this._innerPadding = template['innerPadding'] || 2; 242 | this._width = template['width'] || Game.getScreenWidth() - this._outerPadding; 243 | this._height = template['height'] || Game.getScreenHeight() - this._outerPadding; 244 | this._textWidth = this._width - this._innerPadding; 245 | this._verticalChar = template['verticalChar'] || '|'; 246 | this._horizontalChar = template['horizontalChar'] || '-'; 247 | this._cornerChar = template['cornerChar'] || '+'; 248 | this._highlightColor = template['highlightColor'] || Game.Palette.blue; 249 | 250 | // Menu item settings 251 | this._currentIndex = template['currentIndex'] || 0; 252 | this._menuItems = template['menuItems'] || []; 253 | this._menuActions = template['menuActions'] || []; 254 | this._buildMenuItems = template['buildMenuItems'] || function() { 255 | // The the value of each menu item should be an array of arrays, where the first value of each sub array is a function reference, and the second value is an array of parameters, such that the menu action can be called via menuAction[i][0].apply(this, menuAction[i][1]). This data structure allows for as many function calls with as many arguments to be called sequentially by a single menu action. 256 | var exampleMenuItem = { 257 | 'Example 1': [[console.log, ['This is an example', ', and another.']], [console.log, ['And another!']]], 258 | 'Example 2': [[console.log, ['This is another example', ', and another.']], [console.log, ['And another!!']]] 259 | }; 260 | for(var item in exampleMenuItem) { 261 | this._menuItems.push(item); 262 | this._menuActions.push(exampleMenuItem[item]); 263 | } 264 | }; 265 | this._okFunction = template['ok'] || function() { 266 | var menuActions = this._menuActions[this._currentIndex]; 267 | for (var i = 0; i < menuActions.length; i++) { 268 | if(menuActions[i].length !== 2 && menuActions[i].length !== 3) 269 | throw new Error('Incorrectly formatted action type:', menuActions[i]); 270 | var actionFunc = menuActions[i][0], 271 | actionArgs = menuActions[i][1], 272 | actionContext = (menuActions[i].length === 3) ? menuActions[i][2] : actionFunc; 273 | 274 | actionFunc.apply(actionContext, actionArgs); 275 | } 276 | return true; 277 | }; 278 | }; 279 | Game.Screen.MenuScreen.prototype.setup = function(player, builderArgs) { 280 | this._player = player; 281 | this._currentIndex = 0; // reset current index to 0 282 | this._menuItems = []; // clear out old menu items; 283 | this._menuActions = []; // clear out old menu items; 284 | this._buildMenuItems.apply(this, builderArgs); 285 | }; 286 | Game.Screen.MenuScreen.prototype.render = function(display) { 287 | var startX = this._outerPadding, 288 | startY = this._outerPadding; 289 | 290 | // Draw caption 291 | display.drawText( 292 | Math.round(this._width / 2) - Math.round(this._caption.length / 2), 293 | startY - 1, 294 | '%c{' + Game.Palette.blue + '}' + this._caption + '%c{}' 295 | ); 296 | // Draw menu box 297 | for (var row = 0; row < this._height; row++) { 298 | if(row === 0 || row === this._height - 1) { 299 | display.drawText( 300 | startX, 301 | startY + row, 302 | this._cornerChar.rpad(this._horizontalChar, this._width - 2) + this._cornerChar, 303 | this._width 304 | ); 305 | } else { 306 | display.drawText( 307 | startX, 308 | startY + row, 309 | this._verticalChar.rpad(" ", this._width - 2) + this._verticalChar, 310 | this._width 311 | ); 312 | } 313 | } 314 | 315 | // Draw menu items 316 | for (var item = 0; item < this._menuItems.length; item++) { 317 | var highlight; 318 | if(item === this._currentIndex) 319 | highlight = '%b{' + this._highlightColor + '}'; 320 | else 321 | highlight = '%b{}'; 322 | 323 | display.drawText( 324 | startX + this._innerPadding, 325 | startY + this._innerPadding + item, 326 | highlight + this._menuItems[item] 327 | ); 328 | } 329 | }; 330 | Game.Screen.MenuScreen.prototype.handleInput = function(inputType, inputData) { 331 | var command = Game.Input.handleInput("MenuScreen", inputType, inputData); 332 | var unlock = command ? command() : false; 333 | 334 | // If the return value is true, unlock the engine (player turn over) 335 | if(unlock) 336 | this._player.getMap().getEngine().unlock(); 337 | else 338 | Game.refresh(); 339 | }; 340 | Game.Screen.MenuScreen.prototype.executeOkFunction = function() { 341 | if(this._okFunction) 342 | return this._okFunction(); 343 | else 344 | return false; 345 | }; 346 | Game.Screen.MenuScreen.prototype.moveMenuIndex = function(amount) { 347 | this._currentIndex += amount; 348 | } 349 | -------------------------------------------------------------------------------- /src/tile.js: -------------------------------------------------------------------------------- 1 | // From http://www.codingcookies.com/2013/04/05/building-a-roguelike-in-javascript-part-3a/ 2 | // Base prototype for representing 'tiles', which are environment characters that contain glyphs, and other information such as whether or not the tile is walkable or not. 3 | Game.Tile = function(properties) { 4 | properties = properties || {}; 5 | // Call the Glyph constructor with our properties 6 | Game.Glyph.call(this, properties); 7 | // Set up the properties. We use false by default. 8 | this._name = properties['name'] || false; 9 | this._walkable = properties['walkable'] || false; 10 | this._diggable = properties['diggable'] || false; 11 | this._blocksLight = properties['blocksLight'] || false; 12 | this._outerWall = properties['outerWall'] || false; 13 | this._innerWall = properties['innerWall'] || false; 14 | this._description = properties['description'] || ''; 15 | }; 16 | // Make tiles inherit all the functionality from glyphs 17 | Game.Tile.extend(Game.Glyph); 18 | 19 | // Standard getters 20 | Game.Tile.prototype.isWalkable = function() { 21 | return this._walkable; 22 | }; 23 | Game.Tile.prototype.isDiggable = function() { 24 | return this._diggable; 25 | }; 26 | Game.Tile.prototype.isBlockingLight = function() { 27 | return this._blocksLight; 28 | }; 29 | Game.Tile.prototype.isOuterWall = function() { 30 | return this._outerWall; 31 | }; 32 | Game.Tile.prototype.setOuterWall = function(outerWall) { 33 | this._outerWall = outerWall; 34 | }; 35 | Game.Tile.prototype.isInnerWall = function() { 36 | return this._innerWall; 37 | }; 38 | Game.Tile.prototype.setInnerWall = function(innerWall) { 39 | this._innerWall = innerWall; 40 | }; 41 | Game.Tile.prototype.getDescription = function() { 42 | return this._description; 43 | }; 44 | Game.Tile.prototype.describe = function() { 45 | return this._name; 46 | }; 47 | Game.getNeighborPositions = function(x, y) { 48 | var tiles = []; 49 | // Generate all possible offsets 50 | for (var dX = -1; dX < 2; dX ++) { 51 | for (var dY = -1; dY < 2; dY++) { 52 | // Make sure it isn't the same tile 53 | if (dX == 0 && dY == 0) { 54 | continue; 55 | } 56 | tiles.push({x: x + dX, y: y + dY}); 57 | } 58 | } 59 | return tiles.randomize(); 60 | } -------------------------------------------------------------------------------- /src/tiles.js: -------------------------------------------------------------------------------- 1 | Game.TileRepository = new Game.Repository('tiles', Game.Tile); 2 | 3 | Game.TileRepository.define('null', { 4 | name: 'null', 5 | description: '(unknown)' 6 | }); 7 | Game.TileRepository.define('floor', { 8 | name: 'floor', 9 | character: '.', 10 | walkable: true, 11 | blocksLight: false, 12 | description: 'The floor' 13 | }); 14 | Game.TileRepository.define('wall', { 15 | name: 'wall', 16 | character: '#', 17 | foreground: 'white', 18 | blocksLight: true, 19 | outerWall: true, 20 | description: 'The wall' 21 | }); 22 | Game.TileRepository.define('stairsUp', { 23 | name: 'stairsUp', 24 | character: '<', 25 | foreground: 'white', 26 | walkable: true, 27 | blocksLight: false, 28 | description: 'A staircase leading upwards' 29 | }); 30 | Game.TileRepository.define('stairsDown', { 31 | name: 'stairsDown', 32 | character: '>', 33 | foreground: 'white', 34 | walkable: true, 35 | blocksLight: false, 36 | description: 'A staircase leading downwards' 37 | }); -------------------------------------------------------------------------------- /src/utilities.js: -------------------------------------------------------------------------------- 1 | Game.extend = function(src, dest) { 2 | // Create a copy of the source. 3 | var result = {}; 4 | for (var key in src) { 5 | result[key] = src[key]; 6 | } 7 | // Copy over all keys from dest 8 | for (var key in dest) { 9 | result[key] = dest[key]; 10 | } 11 | return result; 12 | }; 13 | 14 | /** 15 | * There are two ways to instantiate this: one with functions for goals and skip in order to be able to recalculate the dijkstra map, or, with a static list of goals and skips for a static dijkstra map 16 | * 17 | * @param grid two-dimensional array such that grid[x][y] = tile 18 | * @param goals a list of keys and values or a function to that will return true when passed an x,y key and the grid 19 | * @param skip a list of keys and values or a function that will return boolean when passed an x,y key and the grid 20 | */ 21 | Game.DijkstraMap = function(grid, goals, skips) { 22 | this._dijkstraMap = {}; 23 | this._goals = []; 24 | this._skips = []; 25 | this._fill = grid.length * grid[0].length; 26 | this._skipsFunction = null; 27 | this._goalsFunction = null; 28 | this._recalc = true; // denotes when the dijkstraMap needs to be recalculated 29 | this._reInit = false; 30 | this._updateGoals = false; 31 | this._updateSkips = false; 32 | 33 | if(typeof skips === 'function') 34 | this._skipsFunction = skips; 35 | else 36 | this._skips = skips; 37 | 38 | if(typeof goals === 'function') 39 | this._goalsFunction = goals; 40 | else 41 | this._goals = goals; 42 | 43 | // Initialize the dijkstra map with the fill 44 | this.initialize(grid); 45 | 46 | // Create the map 47 | this.update(); 48 | }; 49 | Game.DijkstraMap.prototype.update = function(grid) { 50 | if(this._reInit && grid) 51 | this.initialize(grid); 52 | if(this._updateSkips && grid) 53 | this.updateSkips(grid); 54 | if(this._updateGoals) 55 | this.updateGoals(); 56 | if(this._recalc) 57 | this.calculate(); 58 | }; 59 | Game.DijkstraMap.prototype.initialize = function(grid) { 60 | for(var x = 0; x < grid.length; x++) { 61 | for(var y = 0; y < grid[x].length; y++) { 62 | var coord = x + "," + y; 63 | 64 | if(this._skipsFunction) { 65 | if(this._skipsFunction(coord, grid)) { 66 | this._skips.push(coord); 67 | } else { 68 | this._dijkstraMap[coord] = this._fill; 69 | } 70 | } else { 71 | // As long as the coordinate shouldn't be skipped, fill it 72 | if(this._skips.indexOf(coord) < 0) 73 | this._dijkstraMap[coord] = this._fill; 74 | } 75 | 76 | if(this._goalsFunction) { 77 | if(this._goalsFunction(coord, grid)) { 78 | this._goals.push(coord); 79 | this._dijkstraMap[coord] = 0; 80 | } 81 | } else { 82 | if(this._goals.indexOf(coord) > -1) 83 | this._dijkstraMap[coord] = 0; 84 | } 85 | } 86 | } 87 | this._reInit = false; 88 | }; 89 | Game.DijkstraMap.prototype.setRecalc = function(recalc) { 90 | this._recalc = !!recalc; // Cast to bool 91 | }; 92 | Game.DijkstraMap.prototype.setGoals = function(goals) { 93 | if(typeof goals === 'function') 94 | this._goalsFunction = goals; 95 | else 96 | this._goals = goals; 97 | this._updateGoals = true; 98 | this._recalc = true; 99 | }; 100 | Game.DijkstraMap.prototype.setSkips = function(skips) { 101 | if(typeof skips === 'function') 102 | this._skipsFunction = skips; 103 | else 104 | this._skips = skips; 105 | this._updateSkips = true; 106 | }; 107 | Game.DijkstraMap.prototype.updateGoals = function() { 108 | for(var coord in this._dijkstraMap) { 109 | if(this._goalsFunction) { 110 | if(this._goalsFunction(coord, grid)) { 111 | this._goals.push(coord); 112 | this._dijkstraMap[coord] = 0; 113 | } 114 | } else { 115 | if(this._goals.indexOf(coord) > -1) 116 | this._dijkstraMap[coord] = 0; 117 | } 118 | } 119 | this._updateGoals = false; 120 | }; 121 | Game.DijkstraMap.prototype.updateSkips = function(grid) { 122 | for(var x = 0; x < grid.length; x++) { 123 | for(var y = 0; y < grid[x].length; y++) { 124 | var coord = x + "," + y; 125 | 126 | if(this._skipsFunction) { 127 | if(this._skipsFunction(coord, grid)) { 128 | this._skips.push(coord); 129 | } else { 130 | this._dijkstraMap[coord] = this._fill; 131 | } 132 | } else { 133 | // As long as the coordinate shouldn't be skipped, fill it 134 | if(this._skips.indexOf(coord) < 0) 135 | this._dijkstraMap[coord] = this._fill; 136 | } 137 | } 138 | } 139 | this._updateSkips = false; 140 | this._recalc = true; 141 | }; 142 | Game.DijkstraMap.prototype.calculate = function() { 143 | // A list of tiles to check 144 | var dirtyTiles = []; 145 | for(var coord in this._dijkstraMap) { 146 | dirtyTiles.push(coord); 147 | } 148 | 149 | while(dirtyTiles.length) { 150 | var tile = dirtyTiles.shift(), 151 | tileValue = this._dijkstraMap[tile], 152 | neighbors = this._getNeighbors(tile), // Every tile should have at least 2 neighbors, no matter what 153 | lowestValue = null; 154 | 155 | // Get the lowest-value neighbor 156 | for(var i = 0; i < neighbors.length; i++) { 157 | var neighborValue = this._dijkstraMap[neighbors[i]]; 158 | 159 | if(lowestValue === null) 160 | lowestValue = neighborValue; 161 | else 162 | lowestValue = Math.min(lowestValue, neighborValue); 163 | } 164 | 165 | // If the value of the current tile is at least 2 greater than the lowest-value 166 | // neighbor, the set it to be exactly 1 greater than the lowest value tile, and 167 | // mark all the neigbors as needing to be checked. 168 | if(tileValue - lowestValue >= 2) { 169 | this._dijkstraMap[tile] = lowestValue + 1; 170 | for (var j = 0; j < neighbors.length; j++) { 171 | if(dirtyTiles.indexOf(neighbors[j]) === -1) 172 | dirtyTiles.push(neighbors[j]); 173 | } 174 | } 175 | } 176 | this._recalc = false; 177 | }; 178 | Game.DijkstraMap.prototype._getNeighbors = function(coord) { 179 | var neighbors = [], 180 | offsets = this._getOffsets(coord); 181 | 182 | for (var i = 0; i < offsets.length; i++) { 183 | if(this._dijkstraMap[offsets[i]] !== undefined) { 184 | neighbors.push(offsets[i]); 185 | } 186 | } 187 | 188 | return neighbors; 189 | }; 190 | Game.DijkstraMap.prototype._getOffsets = function(coord) { 191 | var offsets = [], 192 | split = coord.split(","), 193 | x = +split[0], // Cast to number 194 | y = +split[1]; // Cast to number; 195 | 196 | offsets.push(Number(x + 1) + "," + y); 197 | offsets.push(Number(x - 1) + "," + y); 198 | offsets.push(x + "," + Number(y + 1)); 199 | offsets.push(x + "," + Number(y - 1)); 200 | return offsets; 201 | }; 202 | Game.DijkstraMap.prototype.getNext = function(x, y) { 203 | var currVal = this._dijkstraMap[x + "," + y], 204 | offsets = this._getOffsets(x + "," + y); 205 | for (var i = 0; i < offsets.length; i++) { 206 | if(this._dijkstraMap[offsets[i]] < currVal) { 207 | return offsets[i]; 208 | } 209 | } 210 | return false; 211 | }; 212 | Game.DijkstraMap.prototype._consoleLog = function() { 213 | var grid = []; 214 | for(var coord in this._dijkstraMap) { 215 | var x = coord.split(",")[0], 216 | y = coord.split(",")[1]; 217 | if(!grid[x]) 218 | grid[x] = []; 219 | grid[x][y] = this._dijkstraMap[coord]; 220 | } 221 | var output = ""; 222 | for (var y = 0; y < grid[0].length; y++) { 223 | for(var x = 0; x < grid.length; x++) { 224 | if(!grid[x]) 225 | grid[x] = new Array(height); 226 | if(grid[x][y] === undefined || (grid[x][y] > 9 && letters[grid[x][y] - 10] === undefined)) 227 | output += "#"; 228 | else if(grid[x][y] > 9) 229 | output += letters[grid[x][y] - 10]; 230 | else 231 | output += grid[x][y]; 232 | } 233 | output += "\n"; 234 | } 235 | console.log(output); 236 | }; -------------------------------------------------------------------------------- /windows_test.bat: -------------------------------------------------------------------------------- 1 | ECHO ON 2 | TITLE Automated Test Build for hobgoblin 3 | IF NOT EXIST test (mkdir test) 4 | SET /P _deltest= Clear contents of test dir? (y/n) 5 | IF "%_deltest%"=="y" GOTO :deltest 6 | IF "%_deltest%"=="n" GOTO :cdtest 7 | :deltest 8 | del test\js\*.js 9 | rmdir test\js 10 | :cdtest 11 | ECHO Entering test dir... 12 | cd test 13 | node ..\hobgoblin.js init -e 14 | index.html 15 | cd .. 16 | PAUSE 17 | --------------------------------------------------------------------------------