├── .gitignore ├── LICENSE ├── README.md ├── design.css ├── dist └── format.js ├── docs ├── .gitignore ├── CNAME ├── README.md ├── _config.yml ├── _includes │ ├── disqus.html │ ├── footer.html │ ├── google_analytics.html │ ├── header.html │ └── navigation.html ├── _layouts │ ├── default.html │ └── page.html ├── _posts │ ├── .gitkeep │ ├── 2017-08-07-basic-items.md │ ├── 2017-08-07-changelog.md │ ├── 2017-08-07-choose-the-name.md │ ├── 2017-08-07-examples.md │ ├── 2017-08-07-health-and-gold.md │ ├── 2017-08-07-installing.md │ ├── 2017-08-07-item-chooser.md │ ├── 2017-08-07-items.md │ └── 2017-08-07-javascript-basics.md ├── assets │ ├── home-demo.gif │ └── items │ │ ├── weapon_01.png │ │ ├── weapon_02.png │ │ ├── weapon_21.png │ │ ├── weapon_22.png │ │ ├── weapon_221.png │ │ ├── weapon_222.png │ │ ├── weapon_61.png │ │ └── weapon_62.png ├── bin │ └── jekyll-page ├── css │ ├── main.css │ └── syntax.css └── index.md ├── examples └── complete-tutorial.html ├── images └── .gitignore ├── index.html ├── package-lock.json ├── package.json ├── scripts └── item-table-generator.ts ├── src ├── Character.ts ├── CharacterComponent.tsx ├── Choice.ts ├── Interface.tsx ├── Inventory.ts ├── Item.ts ├── ItemComponent.tsx ├── ItemDragDataTransfer.ts ├── Passage.ts ├── Shop.ts ├── Stat.ts ├── Story.ts ├── StoryConfig.ts ├── Tooltip.tsx ├── defaultItems.ts ├── defaultStoryConfig.ts ├── helper.ts └── script.tsx ├── tsconfig.json ├── webpack.config.js └── webpack └── TwineFormatPlugin.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/* 2 | npm-debug.log* 3 | .vscode/ 4 | /dist/* 5 | !/dist/format.js -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Longwelwind 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 | # Adventures 2 | 3 | Adventures is a custom story format for [Twine 2](https://twinery.org) that allows writers to make interactive stories with RPG elements such as health, items, inventory, gold and more. 4 | 5 | ## Usage 6 | 7 | Information about how installing and creating stories in Adventures is [available here](http://longwelwind.github.io/adventures). 8 | 9 | ## Development 10 | 11 | If you wish modify Adventures, you can clone the repository and install the dependencies using: 12 | 13 | ``` 14 | npm install 15 | ``` 16 | 17 | ### Assets 18 | 19 | Adventures uses [7Soul's excellent but-not-free assets](https://7soul.itch.io/). Because I obviously can't redistribute them freely, they aren't included in the repository if you clone it. You will need to buy them if you want to build Adventures. You will need at least: 20 | 21 | * [7Soul's RPG Graphics - Icons](https://7soul.itch.io/7souls-rpg-graphics-pack-1-icons) 22 | * [7Soul's RPG Graphics - UI](https://7soul.itch.io/7souls-rpg-graphics-pack-2-ui) 23 | 24 | Once you obtained them, you need to unzip the archive files into the `images/` folder. Once done, you should have a folder directory that roughly lookes like this: 25 | 26 | ``` 27 | adventures/ 28 | ├─ ... 29 | ├─ images/ 30 | | ├─ Arrows/ 31 | | ├─ Bars/ 32 | | ├─ Buttons/ 33 | | ├─ ... 34 | | ├─ Pack 1B/ 35 | | ├─ Pack 1B-Renamed/ 36 | | └─ ... 37 | ├─ ... 38 | ``` 39 | 40 | ### Debug & Build 41 | 42 | If you want modify Adventures, you will want to build & debug it locally: 43 | 44 | * First launch Webpack using the command: 45 | 46 | ``` 47 | npm run start 48 | ``` 49 | 50 | * In Twine, add the custom story format by clicking `Formats`, then `Add a New Format` and then past the file address to `format.js` which should look like: 51 | ``` 52 | (On Windows) 53 | file:///E:/Users/.../adventures/dist/format.js 54 | (On Unix) 55 | file:///home/.../adventures/dist/format.js 56 | ``` 57 | 58 | With this setup, you don't need to re-add the custom story format everytime you make a modification in the code. Twine will automatically take the newest version of `format.js` everytime you launch your story. -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | *.sw? 2 | _site 3 | _pages 4 | -------------------------------------------------------------------------------- /docs/CNAME: -------------------------------------------------------------------------------- 1 | adventures.longwelwind.net -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | [![Bitdeli Badge](https://d2weczhvl823v0.cloudfront.net/bruth/jekyll-docs-template/trend.png)](https://bitdeli.com/free "Bitdeli Badge") 2 | 3 | Read the docs: http://bruth.github.io/jekyll-docs-template 4 | -------------------------------------------------------------------------------- /docs/_config.yml: -------------------------------------------------------------------------------- 1 | # Site title and subtitle. This is used in _includes/header.html 2 | title: 'Adventures' 3 | subtitle: 'Create Twine stories with RPG elements' 4 | 5 | # if you wish to integrate disqus on pages set your shortname here 6 | disqus_shortname: '' 7 | 8 | # if you use google analytics, add your tracking id here 9 | google_analytics_id: '' 10 | 11 | # Enable/show navigation. There are there options: 12 | # 0 - always hide 13 | # 1 - always show 14 | # 2 - show only if posts are present 15 | navigation: 1 16 | 17 | # URL to source code, used in _includes/footer.html 18 | codeurl: 'https://github.com/bruth/jekyll-docs-template' 19 | 20 | # Default categories (in order) to appear in the navigation 21 | sections: [ 22 | ['basic-usage', 'Basic Usage'], 23 | ['advanced-usage', 'Advanced Usage'], 24 | ['tut', 'Tutorial'], 25 | ['ref', 'Reference'], 26 | ['dev', 'Developers'], 27 | ['others', 'Others'] 28 | ] 29 | 30 | # Keep as an empty string if served up at the root. If served up at a specific 31 | # path (e.g. on GitHub pages) leave off the trailing slash, e.g. /my-project 32 | baseurl: '' 33 | 34 | # Dates are not included in permalinks 35 | permalink: none 36 | 37 | # Syntax highlighting 38 | highlighter: rouge 39 | 40 | # Since these are pages, it doesn't really matter 41 | future: true 42 | 43 | # Exclude non-site files 44 | exclude: ['bin', 'README.md'] 45 | 46 | # Use the kramdown Markdown renderer 47 | markdown: kramdown 48 | redcarpet: 49 | extensions: [ 50 | 'no_intra_emphasis', 51 | 'fenced_code_blocks', 52 | 'autolink', 53 | 'strikethrough', 54 | 'superscript', 55 | 'with_toc_data', 56 | 'tables', 57 | 'hardwrap' 58 | ] 59 | -------------------------------------------------------------------------------- /docs/_includes/disqus.html: -------------------------------------------------------------------------------- 1 |
2 | 13 | 14 | -------------------------------------------------------------------------------- /docs/_includes/footer.html: -------------------------------------------------------------------------------- 1 | Documentation for {{ site.title }} 2 | -------------------------------------------------------------------------------- /docs/_includes/google_analytics.html: -------------------------------------------------------------------------------- 1 | 10 | -------------------------------------------------------------------------------- /docs/_includes/header.html: -------------------------------------------------------------------------------- 1 |

{{ site.title }} 2 | {% if site.subtitle %}{{ site.subtitle }}{% endif %} 3 |

4 | -------------------------------------------------------------------------------- /docs/_includes/navigation.html: -------------------------------------------------------------------------------- 1 | 17 | -------------------------------------------------------------------------------- /docs/_layouts/default.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | {{ site.title }}{% if page.title %} : {{ page.title }}{% endif %} 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 |
18 |
19 | 22 |
23 | 24 |
25 | {% assign post_count = site.posts|size %} 26 | {% if site.navigation != 0 and site.navigation == 1 or post_count > 0 %} 27 | 30 | 31 |
32 | {{ content }} 33 |
34 | {% else %} 35 |
36 | {{ content }} 37 |
38 | {% endif %} 39 |
40 | 41 | {% if page.disqus == 1 %} 42 |
43 | {% if site.navigation == 1 or post_count > 0 %} 44 | 45 |
46 | {% include disqus.html %} 47 |
48 | {% else %} 49 |
50 | {% include disqus.html %} 51 |
52 | {% endif %} 53 |
54 | {% endif %} 55 | 56 |
57 | 60 |
61 |
62 | 63 | 112 | {% if site.google_analytics_id != "" %} 113 | {% include google_analytics.html %} 114 | {% endif %} 115 | 116 | 117 | -------------------------------------------------------------------------------- /docs/_layouts/page.html: -------------------------------------------------------------------------------- 1 | --- 2 | layout: default 3 | --- 4 | 5 | 10 | 11 | {{ content }} 12 | -------------------------------------------------------------------------------- /docs/_posts/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Longwelwind/adventures/afc4c0dc552155d546f9dd28ad32cc8661bccb74/docs/_posts/.gitkeep -------------------------------------------------------------------------------- /docs/_posts/2017-08-07-changelog.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: page 3 | title: "Changelog" 4 | category: others 5 | date: 2017-08-07 21:06:03 6 | --- 7 | 8 | ##### 1.1.1 9 | 10 | * Fixes `removeItem` 11 | 12 | #### 1.1.0 13 | 14 | * Gives the possibility to specify a death passage that will replace the default passage when the player is dead 15 | * Fixes various bugs 16 | 17 | ##### 1.0.2 18 | 19 | * Fixes a bug affecting the dragging of items. 20 | * Adds a proper final message for the death screen. 21 | 22 | ##### 1.0.1 23 | 24 | * Changes the version to `1.0.1` since Twine doesn't handle having 2 story formats with the same semver. 25 | 26 | #### 1.0.0 27 | 28 | * Initial release. -------------------------------------------------------------------------------- /docs/_posts/2017-08-07-choose-the-name.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: page 3 | title: "Changing the hero's name" 4 | category: others 5 | date: 2017-08-07 21:06:04 6 | --- 7 | 8 | ### Setting the name 9 | 10 | The name of the character (displayed at the top of the left panel) is stored in the variable `character.name`. It can be changed by writing in one of your passage: 11 | 12 | ``` 13 | <% 14 | character.name = "Tanis Half-Elven" 15 | %> 16 | ``` 17 | 18 | You can for example put this code in the first passage of your story, if you want the name of the character to be "fixed" (not chosen by the player). 19 | 20 | ### Letting the player choose its name 21 | 22 | This section will show you how to let the player choose its name. Specifically, we will: 23 | 24 | * Make sure the character panel is hidden at the beginning of the game 25 | * Put an text box in the first passage of the game, to let the player choose the name of its character 26 | * Once the player clicks on the link to go to the second passage, display the left panel with the name 27 | 28 | #### Hide the character panel 29 | 30 | In the first passage of the game, add this small script to hide the character panel 31 | 32 | ``` 33 | <% 34 | story.config.displayCharacterPanel = false; 35 | %> 36 | ``` 37 | 38 | #### Add the text box 39 | 40 | Always in the first passage of the game, add this small HTML code to show a text box where the player can write the name of its character 41 | 42 | ``` 43 | 44 | ``` 45 | 46 | #### Display the character panel 47 | 48 | 49 | ``` 50 | <% 51 | story.config.displayCharacterPanel = true; 52 | %> 53 | ``` 54 | 55 | ### Example 56 | 57 | As an example, here is the complete content of the two first passages of a story that would ask the player the name of its character 58 | 59 | #### First passage 60 | 61 | ``` 62 | <% 63 | story.config.displayCharacterPanel = false; 64 | %> 65 | 66 | Welcome adventurer, what is your name ? 67 | 68 |

69 | 70 | [[Confirm|Second]] 71 | ``` 72 | 73 | #### Second passage 74 | 75 | ``` 76 | <% 77 | story.config.displayCharacterPanel = true; 78 | %> 79 | 80 | Your name is <%= character.name %>! 81 | 82 | You wake up in an inn, without any memory of what happened last night... 83 | ``` -------------------------------------------------------------------------------- /docs/_posts/2017-08-07-examples.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: page 3 | title: "Examples" 4 | category: others 5 | date: 2017-08-07 21:06:02 6 | --- 7 | 8 | This page presents some examples (at the moment, only one) of Twine stories using **Adventures**. They serve as "tutorial" and can help you discover how to do cool interactions in your stories. 9 | 10 | To play them, go to the url provided, right-click on the page and `Save Page As...`. Once downloaded, open them in Twine using the `Import from File` button. 11 | 12 | * Complete tutorial: This example shows you how to let the player customizes its name, how to give and remove gold, how to give items and how to customize the death screen. 13 | [Link to the example](https://raw.githubusercontent.com/Longwelwind/adventures/master/examples/complete-tutorial.html) -------------------------------------------------------------------------------- /docs/_posts/2017-08-07-health-and-gold.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: page 3 | title: "Health and Gold" 4 | category: basic-usage 5 | date: 2017-08-07 21:06:03 6 | --- 7 | 8 | ### Health 9 | 10 | You can change the health of the player using the following functions: 11 | 12 | ```javascript 13 | <% 14 | // The character will receive 12 points of damage 15 | character.damage(12); 16 | // The character will be healed of 8 points of damage 17 | character.heal(8); 18 | %> 19 | ``` 20 | 21 | The character has a maximum health of 20. 22 | 23 | Its current health is accessible through ```character.health```, for example: 24 | 25 | ``` 26 | The innkeeper welcomed you into his place. 27 | 28 | <% if (character.health < 10) { %> 29 | 30 | "It looks like you're hurt, I'll get you a bedroom. No need to pay, it looks like you've got enough trouble today already." 31 | 32 | <% } %> 33 | 34 | ``` 35 | 36 | If the health of the character ever falls to 0, the game will trigger a death screen, ending the adventure of the player 37 | 38 | ### Gold 39 | 40 | The amount of gold carried by the player can be changed using the following functions: 41 | 42 | ```javascript 43 | <% 44 | // Add 45 gold to the inventory of the player 45 | character.addGold(45); 46 | // Remove 25 gold to the inventory of the player 47 | character.removeGold(25); 48 | %> 49 | ``` 50 | 51 | The amount of gold carried by the player can be accessed through `character.gold`. For example, you can offer the player to buy an item: 52 | 53 | ``` 54 | You ask for the price of the sword. The merchant, after peaking at your purse, agrees to sell it to you for 10 gold. 55 | 56 | <% if (character.gold >= 10) { %> 57 | [["Okay !"|Buy the sword]] 58 | <% } %> 59 | 60 | [["I don't want it"|Continue]] 61 | ``` 62 | 63 | The link to the passage `Buy the sword` will only appear if the player has at least 10 gold. 64 | 65 | In the passage `Buy the sword`, you can then write: 66 | 67 | ``` 68 | <% 69 | character.removeGold(10); // Remove the gold 70 | character.inventory.addItem("sword"); // Give a sword to the player 71 | %> 72 | 73 | "Hope you'll make good use of it !" 74 | ``` -------------------------------------------------------------------------------- /docs/_posts/2017-08-07-installing.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: page 3 | title: "Installing" 4 | category: basic-usage 5 | date: 2017-08-07 21:06:05 6 | --- 7 | 8 | To install **Adventures** in Twine 2, click `Formats` in the main menu, then `Add a New Format` and paste the url below into the box. Finally, click `Add`. 9 | 10 | ``` 11 | https://cdn.rawgit.com/Longwelwind/adventures/master/dist/format.js 12 | ``` 13 | 14 | You can now create new story or edit an existing one and change the story format for your story. 15 | 16 | **Note:** If you're using the web version of Twine 2 (inside your browser), you may run into an error. Try to use the Windows/Linux/OSX version instead! 17 | 18 | ### Interactions 19 | 20 | **Adventures** is based on [klembot's Snowman 2](https://bitbucket.org/klembot/snowman-2). The content of the passages are processed by [lodash's _.template function](https://lodash.com/docs/4.17.4#template), which allows you to execute Javascript scripts inside the `<% %>` tags, and print dynamic values using the `<%= %>` tags. 21 | 22 | For example, the passage: 23 | 24 | ``` 25 | <% 26 | var name = "Arthas"; 27 | %> 28 | 29 | The old pirate greeted you: "Ahoy, you must be <%= name %>" ! 30 | ``` 31 | 32 | Would become, once in the story: `The old pirate greeted you: "Ahoy, you must be Arthas" !`. 33 | 34 | You can also conditionaly print content using the classic `if` Javascript structure: 35 | 36 | ``` 37 | Along the road, you stumble upon a group of bandits 38 | 39 | <% if (character.money >= 100) { %> 40 | 41 | They notice that your purse seems to be quite heavy 42 | 43 | <% } %> 44 | ``` 45 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /docs/_posts/2017-08-07-items.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: page 3 | title: "Items" 4 | category: advanced-usage 5 | date: 2017-08-07 21:06:03 6 | --- 7 | 8 | ### Custom items 9 | 10 | **Adventures** comes with a list of predefined items, but you can define your own custom items to use in your stories. 11 | 12 | Items are defined in the configuration of the story. In Twine 2, in the view of your passages, click on the name of your story in the bottom-left of the screen then click on `Edit Story Javascript`. In it, you can put, for example: 13 | 14 | ```js 15 | window.config = { 16 | "items": [ 17 | { 18 | "tag": "sword", 19 | "name": "Steel Sword", 20 | "x": 1, "y": 107 21 | }, 22 | { 23 | "tag": "shield", 24 | "name": "Shield", 25 | "x": 2, "y": 71 26 | }, 27 | { 28 | "tag": "red-potion", 29 | "name": "Health Potion", 30 | "x": 4, "y": 41 31 | } 32 | ] 33 | } 34 | ``` 35 | 36 | The `tag` field is the "programming" name you will use in other function calls, you should only use lowercase alphanumeric characters and use `-` instead of whitespace. The `name` field is the "pretty" name that will be displayed in-game. 37 | 38 | `x` and `y` represents the coordinates of the item in the spritesheet. You can use the [Item chooser]({{ site.baseurl }}{% post_url 2017-08-07-item-chooser %}) to find the coordinate of a specific sprite. 39 | 40 | You can add as many items as you want (don't forget the trailing `,` after each item's closing `}`). -------------------------------------------------------------------------------- /docs/_posts/2017-08-07-javascript-basics.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: page 3 | title: "Javascript basics" 4 | category: basic-usage 5 | date: 2017-08-07 21:06:04 6 | --- 7 | 8 | **Adventures** uses Javascript as a scripting language. Code can be written inside the `<% %>` tags, and values can be printed using the `<%= %>` tags. If you have no knowledge of Javascript, this section will introduce you the basic to make a story. 9 | 10 | ### State 11 | 12 | You can remember values in named variables using the following syntax: 13 | 14 | ```javascript 15 | <% 16 | answer = "accepted"; 17 | count = 45; 18 | %> 19 | ``` 20 | 21 | In this example, we stored the string of characters `Accepted` in a variable called `answer` and the number `45` in a variable called `count`. 22 | 23 | You can print the content of a variable using the `<%= %>` (Note the `=` sign in the tag). For example: 24 | 25 | ``` 26 | When the mage made an offer, you <%= answer %> it. 27 | 28 | You have killed <%= count %> orcs 29 | ``` 30 | 31 | Once in the story, this would be printed as: 32 | 33 | ``` 34 | When the mage made an offer, you accepted it. 35 | 36 | You have killed 45 orcs 37 | ``` 38 | 39 | ## Conditional writing 40 | 41 | You can print text conditionaly, depending on precise conditions. Using the previous variables, we can do, for example: 42 | 43 | ``` 44 | <% 45 | if (offer == "accepted") { 46 | %> 47 | 48 | You did accept the offer when you had the choice. 49 | 50 | <% 51 | } 52 | %> 53 | ``` 54 | 55 | **Note** the double `=` sign, the opening `{` and the corresponding closing `}`. In this example, the text will only be printed if the variable `offer` contains the value `"accepted"`. 56 | 57 | An other example, here using the `<` operator: 58 | 59 | ``` 60 | <% 61 | if (count < 50) { 62 | %> 63 | 64 | You didn't kill enough orcs to satisfy the master. 65 | 66 | <% 67 | } 68 | %> 69 | ``` 70 | 71 | ## Functions 72 | 73 | Most of the interactions with **Adventures** is done through functions. A function has a *name*, and you give to it *arguments* when you call it. For example, to give money to the player, you would write: 74 | 75 | ``` 76 | You get some gold in the purse of the soldier 77 | 78 | <% 79 | character.addGold(45); 80 | %> 81 | ``` 82 | 83 | In this case, you call the function `character.addGold` is the name of the function, and `45` is the argument you give to it. 84 | 85 | The following sections of the documentation describe the different functions you can use to manage your story. -------------------------------------------------------------------------------- /docs/assets/home-demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Longwelwind/adventures/afc4c0dc552155d546f9dd28ad32cc8661bccb74/docs/assets/home-demo.gif -------------------------------------------------------------------------------- /docs/assets/items/weapon_01.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Longwelwind/adventures/afc4c0dc552155d546f9dd28ad32cc8661bccb74/docs/assets/items/weapon_01.png -------------------------------------------------------------------------------- /docs/assets/items/weapon_02.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Longwelwind/adventures/afc4c0dc552155d546f9dd28ad32cc8661bccb74/docs/assets/items/weapon_02.png -------------------------------------------------------------------------------- /docs/assets/items/weapon_21.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Longwelwind/adventures/afc4c0dc552155d546f9dd28ad32cc8661bccb74/docs/assets/items/weapon_21.png -------------------------------------------------------------------------------- /docs/assets/items/weapon_22.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Longwelwind/adventures/afc4c0dc552155d546f9dd28ad32cc8661bccb74/docs/assets/items/weapon_22.png -------------------------------------------------------------------------------- /docs/assets/items/weapon_221.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Longwelwind/adventures/afc4c0dc552155d546f9dd28ad32cc8661bccb74/docs/assets/items/weapon_221.png -------------------------------------------------------------------------------- /docs/assets/items/weapon_222.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Longwelwind/adventures/afc4c0dc552155d546f9dd28ad32cc8661bccb74/docs/assets/items/weapon_222.png -------------------------------------------------------------------------------- /docs/assets/items/weapon_61.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Longwelwind/adventures/afc4c0dc552155d546f9dd28ad32cc8661bccb74/docs/assets/items/weapon_61.png -------------------------------------------------------------------------------- /docs/assets/items/weapon_62.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Longwelwind/adventures/afc4c0dc552155d546f9dd28ad32cc8661bccb74/docs/assets/items/weapon_62.png -------------------------------------------------------------------------------- /docs/bin/jekyll-page: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require 'date' 4 | require 'optparse' 5 | 6 | options = { 7 | # Expects to be in the bin/ sub-directory by default 8 | :path => File.dirname(File.dirname(__FILE__)) 9 | } 10 | 11 | parser = OptionParser.new do |opt| 12 | opt.banner = 'usage: jekyll-page TITLE CATEGORY [FILENAME] [OPTIONS]' 13 | opt.separator '' 14 | opt.separator 'Options' 15 | opt.on('-e', '--edit', 'Edit the page') do |edit| 16 | options[:edit] = true 17 | end 18 | opt.on('-l', '--link', 'Relink pages') do |link| 19 | options[:link] = true 20 | end 21 | opt.on('-p PATH', '--path PATH', String, 'Path to project root') do |path| 22 | options[:path] = path 23 | end 24 | opt.separator '' 25 | end 26 | 27 | parser.parse! 28 | 29 | title = ARGV[0] 30 | category = ARGV[1] 31 | filename = ARGV[2] 32 | 33 | # Resolve any relative links 34 | BASE_DIR = File.expand_path(options[:path]) 35 | POSTS_DIR = "#{BASE_DIR}/_posts" 36 | PAGES_DIR = "#{BASE_DIR}/_pages" 37 | 38 | # Ensure the _posts directory exists (we are in the correct directory) 39 | if not Dir.exists?(POSTS_DIR) 40 | puts "#{POSTS_DIR} directory does not exists" 41 | exit 42 | end 43 | 44 | # Create _pages directory if it doesn't exist 45 | if not Dir.exists?(PAGES_DIR) 46 | Dir.mkdir(PAGES_DIR) 47 | end 48 | 49 | if options[:link] 50 | Dir.foreach(POSTS_DIR) do |name| 51 | next if name[0] == '.' 52 | nodate = name[/\d{4}-\d{2}-\d{2}-(?.*)/, 'rest'] 53 | if File.symlink?("#{PAGES_DIR}/#{nodate}") 54 | File.delete("#{PAGES_DIR}/#{nodate}") 55 | end 56 | abspath = File.absolute_path("#{POSTS_DIR}/#{name}") 57 | File.symlink(abspath, "#{PAGES_DIR}/#{nodate}") 58 | end 59 | end 60 | 61 | if not title or not category 62 | # This flag can be used by itself, exit silently if no arguments 63 | # are defined 64 | if not options[:link] 65 | puts parser 66 | end 67 | exit 68 | end 69 | 70 | if not filename 71 | filename = title.downcase.gsub(/[^a-z0-9\s]/, '').gsub(/\s+/, '-') 72 | end 73 | 74 | today=Date.today().strftime('%F') 75 | now=DateTime.now().strftime('%F %T') 76 | 77 | filepath = "#{POSTS_DIR}/#{today}-#{filename}.md" 78 | symlink = "#{PAGES_DIR}/#{filename}.md" 79 | 80 | if File.exists?(filepath) 81 | puts "File #{filepath} already exists" 82 | exit 83 | end 84 | 85 | content = < .page-header:first-child { 42 | margin-top: 0; 43 | } 44 | 45 | #content > .page-header:first-child h2 { 46 | margin-top: 0; 47 | } 48 | 49 | 50 | #navigation { 51 | font-size: 0.9em; 52 | } 53 | 54 | #navigation li a { 55 | padding-left: 10px; 56 | padding-right: 10px; 57 | } 58 | 59 | #navigation .nav-header { 60 | padding-left: 0; 61 | padding-right: 0; 62 | } 63 | 64 | body.rtl { 65 | direction: rtl; 66 | } 67 | 68 | body.rtl #header .brand { 69 | float: right; 70 | margin-left: 5px; 71 | } 72 | body.rtl .row-fluid [class*="span"] { 73 | float: right !important; 74 | margin-left: 0; 75 | margin-right: 2.564102564102564%; 76 | } 77 | body.rtl .row-fluid [class*="span"]:first-child { 78 | margin-right: 0; 79 | } 80 | 81 | body.rtl ul, body.rtl ol { 82 | margin: 0 25px 10px 0; 83 | } 84 | 85 | table { 86 | margin-bottom: 1rem; 87 | border: 1px solid #e5e5e5; 88 | border-collapse: collapse; 89 | } 90 | 91 | td, th { 92 | padding: .25rem .5rem; 93 | border: 1px solid #e5e5e5; 94 | } 95 | 96 | p > img { 97 | display: block; 98 | margin: 0 auto; 99 | padding: 10px; 100 | border: 1px solid #333; 101 | } -------------------------------------------------------------------------------- /docs/css/syntax.css: -------------------------------------------------------------------------------- 1 | .highlight .hll { background-color: #ffffcc } 2 | .highlight { background: #ffffff; } 3 | .highlight .c { color: #888888 } /* Comment */ 4 | .highlight .err { color: #a61717; background-color: #e3d2d2 } /* Error */ 5 | .highlight .k { color: #008800; font-weight: bold } /* Keyword */ 6 | .highlight .cm { color: #888888 } /* Comment.Multiline */ 7 | .highlight .cp { color: #cc0000; font-weight: bold } /* Comment.Preproc */ 8 | .highlight .c1 { color: #888888 } /* Comment.Single */ 9 | .highlight .cs { color: #cc0000; font-weight: bold; background-color: #fff0f0 } /* Comment.Special */ 10 | .highlight .gd { color: #000000; background-color: #ffdddd } /* Generic.Deleted */ 11 | .highlight .ge { font-style: italic } /* Generic.Emph */ 12 | .highlight .gr { color: #aa0000 } /* Generic.Error */ 13 | .highlight .gh { color: #333333 } /* Generic.Heading */ 14 | .highlight .gi { color: #000000; background-color: #ddffdd } /* Generic.Inserted */ 15 | .highlight .go { color: #888888 } /* Generic.Output */ 16 | .highlight .gp { color: #555555 } /* Generic.Prompt */ 17 | .highlight .gs { font-weight: bold } /* Generic.Strong */ 18 | .highlight .gu { color: #666666 } /* Generic.Subheading */ 19 | .highlight .gt { color: #aa0000 } /* Generic.Traceback */ 20 | .highlight .kc { color: #008800; font-weight: bold } /* Keyword.Constant */ 21 | .highlight .kd { color: #008800; font-weight: bold } /* Keyword.Declaration */ 22 | .highlight .kn { color: #008800; font-weight: bold } /* Keyword.Namespace */ 23 | .highlight .kp { color: #008800 } /* Keyword.Pseudo */ 24 | .highlight .kr { color: #008800; font-weight: bold } /* Keyword.Reserved */ 25 | .highlight .kt { color: #888888; font-weight: bold } /* Keyword.Type */ 26 | .highlight .m { color: #0000DD; font-weight: bold } /* Literal.Number */ 27 | .highlight .s { color: #dd2200; background-color: #fff0f0 } /* Literal.String */ 28 | .highlight .na { color: #336699 } /* Name.Attribute */ 29 | .highlight .nb { color: #003388 } /* Name.Builtin */ 30 | .highlight .nc { color: #bb0066; font-weight: bold } /* Name.Class */ 31 | .highlight .no { color: #003366; font-weight: bold } /* Name.Constant */ 32 | .highlight .nd { color: #555555 } /* Name.Decorator */ 33 | .highlight .ne { color: #bb0066; font-weight: bold } /* Name.Exception */ 34 | .highlight .nf { color: #0066bb; font-weight: bold } /* Name.Function */ 35 | .highlight .nl { color: #336699; font-style: italic } /* Name.Label */ 36 | .highlight .nn { color: #bb0066; font-weight: bold } /* Name.Namespace */ 37 | .highlight .py { color: #336699; font-weight: bold } /* Name.Property */ 38 | .highlight .nt { color: #bb0066; font-weight: bold } /* Name.Tag */ 39 | .highlight .nv { color: #336699 } /* Name.Variable */ 40 | .highlight .ow { color: #008800 } /* Operator.Word */ 41 | .highlight .w { color: #bbbbbb } /* Text.Whitespace */ 42 | .highlight .mf { color: #0000DD; font-weight: bold } /* Literal.Number.Float */ 43 | .highlight .mh { color: #0000DD; font-weight: bold } /* Literal.Number.Hex */ 44 | .highlight .mi { color: #0000DD; font-weight: bold } /* Literal.Number.Integer */ 45 | .highlight .mo { color: #0000DD; font-weight: bold } /* Literal.Number.Oct */ 46 | .highlight .sb { color: #dd2200; background-color: #fff0f0 } /* Literal.String.Backtick */ 47 | .highlight .sc { color: #dd2200; background-color: #fff0f0 } /* Literal.String.Char */ 48 | .highlight .sd { color: #dd2200; background-color: #fff0f0 } /* Literal.String.Doc */ 49 | .highlight .s2 { color: #dd2200; background-color: #fff0f0 } /* Literal.String.Double */ 50 | .highlight .se { color: #0044dd; background-color: #fff0f0 } /* Literal.String.Escape */ 51 | .highlight .sh { color: #dd2200; background-color: #fff0f0 } /* Literal.String.Heredoc */ 52 | .highlight .si { color: #3333bb; background-color: #fff0f0 } /* Literal.String.Interpol */ 53 | .highlight .sx { color: #22bb22; background-color: #f0fff0 } /* Literal.String.Other */ 54 | .highlight .sr { color: #008800; background-color: #fff0ff } /* Literal.String.Regex */ 55 | .highlight .s1 { color: #dd2200; background-color: #fff0f0 } /* Literal.String.Single */ 56 | .highlight .ss { color: #aa6600; background-color: #fff0f0 } /* Literal.String.Symbol */ 57 | .highlight .bp { color: #003388 } /* Name.Builtin.Pseudo */ 58 | .highlight .vc { color: #336699 } /* Name.Variable.Class */ 59 | .highlight .vg { color: #dd7700 } /* Name.Variable.Global */ 60 | .highlight .vi { color: #3333bb } /* Name.Variable.Instance */ 61 | .highlight .il { color: #0000DD; font-weight: bold } /* Literal.Number.Integer.Long */ 62 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: default 3 | title: "Home" 4 | --- 5 | 6 | ### What is Adventures 7 | 8 | **Adventures** is a custom story format for [Twine 2](https://twinery.org/) made by [Longwelwind](https://twitter.com/Longwelwind) that allows writers to add RPG elements such as health, items, golds and more to their story. The project is free and open source; feel free to contribute on the [Github repository](https://github.com/Longwelwind/adventures) ! 9 | 10 | ![Demo of Adventures]({{ site.baseurl }}/assets/home-demo.gif) 11 | 12 | The documentation is split into 2 sections: 13 | 14 | * **Basic Usage** shows how to use the basic functions of **Adventures** and is perfect for beginners, or those who don't possess sufficient Javascript knowledge as most scripts can be copy/pasted into your story. This section will show you how to change the health of the player, change the gold it holds and add or remove items in its inventory. 15 | * **Advanced Usage** shows the advanced features of **Adventures** and is reserved for those who possess an intermediate knowledge of Javascript and want to make more complex interactions. This section will show you how to create custom items, for example. 16 | 17 | ### Requirements 18 | 19 | **Adventures** is based on [klembot's Snowman 2](https://bitbucket.org/klembot/snowman-2) and works by calling Javascript functions inside `<% %>` tags to change the state of the character and the story. For example, to add a sword to the inventory of the player, you'd write in one of your passages: 20 | 21 | ``` 22 | In the middle of the hall, amidst the corpses of the goblins, you find a sword. You're definitly not the first one to wield it, but it should suffice should a monster bar your way. 23 | 24 | <% character.inventory.addItem("sword"); %> 25 | ``` 26 | 27 | If you have no knowledge of Javascript, a small section describing the basics of Javascript will help you get the minimum skill to use Adventures. 28 | -------------------------------------------------------------------------------- /examples/complete-tutorial.html: -------------------------------------------------------------------------------- 1 | 108 | 109 | -------------------------------------------------------------------------------- /images/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | {{STORY_NAME}} 4 | 7 | 8 | 9 |
10 | {{STORY_DATA}} 11 | 12 | 13 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "twine", 3 | "version": "1.1.1", 4 | "description": "", 5 | "main": "webpack.config.js", 6 | "dependencies": { 7 | "@types/jimp": "^0.2.1", 8 | "@types/lodash": "^4.14.70", 9 | "@types/marked": "0.0.28", 10 | "@types/react": "^15.0.38", 11 | "@types/react-dom": "^15.5.1", 12 | "css-loader": "^0.28.4", 13 | "extract-text-webpack-plugin": "^3.0.0", 14 | "jimp": "^0.2.28", 15 | "lodash": "^4.17.4", 16 | "marked": "^0.3.6", 17 | "mobx": "^3.2.1", 18 | "mobx-react": "^4.2.2", 19 | "react": "^15.6.1", 20 | "react-dom": "^15.6.1", 21 | "react-transition-group": "^2.2.0", 22 | "ts-loader": "^2.3.1", 23 | "typescript": "^2.4.2", 24 | "uglify-js": "^3.0.25", 25 | "uglifyjs-webpack-plugin": "^0.4.6", 26 | "webpack": "^3.3.0" 27 | }, 28 | "devDependencies": {}, 29 | "scripts": { 30 | "start": "webpack --watch", 31 | "build": "webpack -p" 32 | }, 33 | "author": "", 34 | "license": "ISC" 35 | } 36 | -------------------------------------------------------------------------------- /scripts/item-table-generator.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This script takes the item defined by the config 3 | * and prints a Markdown table that can be put in the 4 | * documentation 5 | */ 6 | import defaultItems from "../src/defaultItems"; 7 | 8 | const itemPerRow = 2; 9 | const size = 32; 10 | 11 | console.log(""); 12 | 13 | console.log(""); 14 | for (let i = 0;i < itemPerRow;i++) { 15 | console.log(""); 16 | } 17 | console.log(""); 18 | 19 | for (let i = 0;i < Math.ceil(defaultItems.length / itemPerRow);i++) { 20 | console.log(""); 21 | for (let j = 0;j < itemPerRow;j++) { 22 | let index = i*itemPerRow + j; 23 | 24 | if (index < defaultItems.length) { 25 | let item = defaultItems[index]; 26 | 27 | console.log(``); 28 | } else { 29 | // This should be an empty cell 30 | console.log(``); 31 | } 32 | } 33 | 34 | console.log(""); 35 | } 36 | 37 | console.log("
tagnameicon
${item.tag}${item.name}
"); -------------------------------------------------------------------------------- /src/Character.ts: -------------------------------------------------------------------------------- 1 | import { Stat } from './Stat'; 2 | import { observable } from 'mobx'; 3 | import Inventory from "./Inventory"; 4 | import Story from "./Story"; 5 | 6 | export default class Character { 7 | @observable name: string = "Aramis"; 8 | inventory: Inventory = new Inventory(this.story, 16); 9 | maxHealth: number = 20; 10 | @observable health: number = 20; 11 | @observable gold: number = 0; 12 | 13 | get dead(): boolean { 14 | return this.health == 0; 15 | } 16 | 17 | // Bad design, but it is easier to code than to make a `CharacterStat` 18 | // entity that has a `stat` and a `count` field. 19 | stats: { [key: string]: number } = {}; 20 | 21 | constructor(public story: Story) { 22 | this.story.stats.forEach(s => { 23 | this.stats[s.name] = 0; 24 | }); 25 | } 26 | 27 | damage(amount: number) { 28 | if (amount < 0) { 29 | throw new Error(`damage: "amount" must be positive`); 30 | } 31 | 32 | this.health = Math.max(this.health - amount, 0); 33 | } 34 | 35 | heal(amount: number) { 36 | if (amount < 0) { 37 | throw new Error(`heal: "amount" must be positive`); 38 | } 39 | 40 | this.health = Math.min(this.health + amount, this.maxHealth); 41 | } 42 | 43 | addGold(gold: number) { 44 | if (gold <= 0) { 45 | return; 46 | } 47 | 48 | this.gold += gold; 49 | } 50 | 51 | removeGold(gold: number) { 52 | if (gold <= 0) { 53 | return; 54 | } 55 | 56 | this.gold -= gold; 57 | } 58 | 59 | hasGold(gold: number) { 60 | return this.gold >= gold; 61 | } 62 | 63 | getStat(tag: string): number { 64 | let stat = this.story.getStat(tag); 65 | 66 | return this.stats[stat.name]; 67 | } 68 | 69 | addStat(tag: string, count: number): number { 70 | let stat = this.story.getStat(tag); 71 | 72 | if (count < 0) { 73 | throw new Error("addStat: `count` should be positive"); 74 | } 75 | 76 | this.stats[stat.name] += count; 77 | return this.stats[stat.name]; 78 | } 79 | 80 | removeStat(tag: string, count: number): number { 81 | let stat = this.story.getStat(tag); 82 | 83 | if (count < 0) { 84 | throw new Error("setStat: `count` should be positive"); 85 | } 86 | 87 | this.stats[stat.name] = Math.max(this.stats[stat.name] - count, 0); 88 | return this.stats[stat.name]; 89 | } 90 | 91 | setStat(tag: string, count: number): void { 92 | let stat = this.story.getStat(tag); 93 | 94 | if (count < 0) { 95 | throw new Error("setStat: `count` should be positive"); 96 | } 97 | 98 | this.stats[stat.name] = count; 99 | } 100 | 101 | hasStat(tag: string, count: number): boolean { 102 | let stat = this.story.getStat(tag); 103 | 104 | return this.stats[stat.name] >= count; 105 | } 106 | } -------------------------------------------------------------------------------- /src/CharacterComponent.tsx: -------------------------------------------------------------------------------- 1 | import { observer } from 'mobx-react'; 2 | import * as React from "react"; 3 | import Story from "./Story"; 4 | import Character from "./Character"; 5 | import ItemComponent from "./ItemComponent"; 6 | import Interface from './Interface'; 7 | 8 | interface CharacterComponentProps { 9 | story: Story; 10 | } 11 | 12 | @observer 13 | export default class CharacterComponent extends React.Component { 14 | 15 | get story(): Story { 16 | return this.props.story; 17 | } 18 | 19 | get character(): Character { 20 | return this.props.story.character; 21 | } 22 | 23 | render() { 24 | return ( 25 |
26 |
27 |
28 |
29 |

30 | {this.props.story.character.name} 31 |

32 |
33 |
34 | {this.story.config.enableHealth && ( 35 |
36 |
37 |
38 |
42 |
43 |
44 |
45 |
46 | )} 47 | {this.story.config.enableStats && ( 48 |
49 |
50 |
51 | {this.story.stats.map(stat => ( 52 |
53 | {stat.name}: {this.character.getStat(stat.name)} 54 |
55 | ))} 56 |
57 |
58 |
59 | )} 60 |
61 |
62 |
63 |
64 | {this.character.inventory.items.map((item, i) => ( 65 | Interface.onItemDragStart(this.character.inventory, i)} 69 | onDrop={() => Interface.onItemDrop(this.character.inventory, i)} 70 | /> 71 | ))} 72 |
73 |
74 |
75 | {this.story.config.enableGold && ( 76 |
77 |
78 |
79 |
80 | {this.character.gold} 81 |
82 |
83 |
84 |
85 |
86 |
87 |
88 | )} 89 |
90 |
91 | ); 92 | } 93 | } -------------------------------------------------------------------------------- /src/Choice.ts: -------------------------------------------------------------------------------- 1 | import Passage from "./Passage"; 2 | 3 | export default class Choice { 4 | readonly BUTTON_PREFIX = "button-"; 5 | readonly BUTTON_THEMES = ["blue", "red", "yellow", "green", "pink", "black", "white"]; 6 | 7 | constructor(public passage: Passage, public text: string) { 8 | 9 | } 10 | 11 | getTheme(defaultTheme: string): string { 12 | return this.BUTTON_THEMES.reduce((p, c) => this.passage.tags.indexOf(this.BUTTON_PREFIX + c) != -1 ? c : p, defaultTheme); 13 | } 14 | } -------------------------------------------------------------------------------- /src/Interface.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { observer } from "mobx-react"; 3 | import Story from "./Story"; 4 | import { observable } from "mobx"; 5 | import Choice from "./Choice"; 6 | import Character from "./Character"; 7 | import Passage from "./Passage"; 8 | import * as CSSTransition from "react-transition-group/CSSTransition"; 9 | import CharacterComponent from "./CharacterComponent"; 10 | import ItemComponent from "./ItemComponent"; 11 | import ItemDragDataTransfer from "./ItemDragDataTransfer"; 12 | import Inventory from "./Inventory"; 13 | 14 | interface InterfaceProps { 15 | story: Story 16 | } 17 | 18 | @observer 19 | export default class Interface extends React.Component { 20 | // Fade-in animation times 21 | readonly PASSAGE_FADE_IN_DURATION = 500; 22 | readonly CHOICE_FADE_IN_DELAY = 800; 23 | readonly CHOICE_FADE_IN_DELAY_PER = 400; 24 | readonly CHOICE_FADE_IN_DURATION = 500; 25 | readonly PROP_FADE_IN_DELAY = 400; 26 | readonly PROP_FADE_IN_DURATION = 400; 27 | 28 | // Fade-out animation times 29 | readonly CHOICE_CHOSEN_FADE_OUT_DELAY = 400; 30 | 31 | @observable choiceChosen: Choice = null; 32 | @observable deadChoiceChosen: boolean = false; 33 | 34 | get story(): Story { 35 | return this.props.story; 36 | } 37 | 38 | get character(): Character { 39 | return this.props.story.character; 40 | } 41 | 42 | get currentPassage(): Passage { 43 | return this.story.currentPassage; 44 | } 45 | 46 | render() { 47 | return ( 48 |
49 |
50 |
51 | 60 | 61 | 62 |
63 | {this.story.error != null ? ( 64 |
65 |
66 |
67 |
68 |
69 | Error: {this.story.error} 70 |
71 |
72 |
73 |
74 |
75 | ) : ( 76 |
77 |
78 |
79 | 88 |
98 |
99 |
100 |
104 |
105 |
106 |
107 |
108 |
109 |
110 |
111 | {this.story.lootableInventory != null && ( 112 |
113 |
114 | 123 |
133 |
134 |
135 | {this.story.lootableInventory.items.map((item, i) => ( 136 | Interface.onItemDragStart(this.story.lootableInventory, i)} 140 | onDrop={() => Interface.onItemDrop(this.story.lootableInventory, i)} 141 | /> 142 | ))} 143 |
144 |
145 |
146 |
147 |
148 |
149 | )} 150 | {this.story.shop != null && ( 151 |
152 |
153 | 162 |
172 |
173 | {this.story.shop.entries.map((entry, i) => ( 174 |
175 |
176 |
177 | Interface.onItemDragStart(this.story.shop.inventory, i)} 182 | onDrop={() => Interface.onItemDrop(this.story.shop.inventory, i)} 183 | /> 184 |
185 | {!entry.bought ? ( 186 |
187 |
188 |
189 |
190 |
191 | {entry.price} 192 |
193 |
194 |
195 |
196 |
197 |
198 |
199 | 206 |
207 |
208 |
209 | ) : ( 210 |
211 | {/* 212 | This div is there to still take the remaining place 213 | and keep the item-slot on the left-side 214 | */} 215 |
216 | )} 217 |
218 |
219 | ))} 220 |
221 |
222 |
223 |
224 |
225 | )} 226 |
227 | {this.story.choices.map((c, i) => ( 228 |
229 |
230 | 239 | 252 | 253 |
254 |
255 | ))} 256 |
257 |
258 | )} 259 |
260 |
261 | ); 262 | } 263 | 264 | onChoice(choice: Choice) { 265 | if (this.choiceChosen != null) { 266 | return; 267 | } 268 | 269 | this.choiceChosen = choice; 270 | 271 | setTimeout(() => { 272 | this.choiceChosen = null; 273 | this.story.choose(choice); 274 | }, this.CHOICE_CHOSEN_FADE_OUT_DELAY + this.PASSAGE_FADE_IN_DURATION); 275 | } 276 | 277 | isTransitioning(): boolean { 278 | return this.deadChoiceChosen || this.choiceChosen != null; 279 | } 280 | 281 | static onItemDragStart(inventory: Inventory, i: number) { 282 | ItemDragDataTransfer.create(inventory, i); 283 | } 284 | 285 | static onItemDrop(toInventory: Inventory, toI: number) { 286 | let dataTransfer = ItemDragDataTransfer.i; 287 | 288 | let fromInventory = dataTransfer.inventory; 289 | let fromI = dataTransfer.i; 290 | 291 | if (fromInventory.items[fromI] == null) { 292 | return; 293 | } 294 | 295 | let replacedItem = toInventory.items[toI]; 296 | toInventory.items[toI] = fromInventory.items[fromI]; 297 | fromInventory.items[fromI] = replacedItem; 298 | } 299 | } -------------------------------------------------------------------------------- /src/Inventory.ts: -------------------------------------------------------------------------------- 1 | import { observable } from 'mobx'; 2 | import Story from "./Story"; 3 | import Item from "./Item"; 4 | import * as _ from "lodash"; 5 | 6 | export default class Inventory { 7 | @observable items: Item[]; 8 | 9 | constructor(private story: Story, public readonly length: number) { 10 | this.items = observable.shallowArray(_.times(this.length, i => null)); 11 | } 12 | 13 | addItem(itemTag: string): boolean { 14 | let item = this.story.getItem(itemTag); 15 | 16 | return this.add(item); 17 | } 18 | 19 | add(item: Item): boolean { 20 | // We find the first index that contains `null` 21 | let i = this.items.reduceRight((p, c, i) => c == null ? i : p, -1); 22 | if (i == -1) { 23 | return false; 24 | } 25 | 26 | this.items[i] = item; 27 | 28 | return true; 29 | } 30 | 31 | hasItem(itemTag: string): boolean { 32 | let item = this.story.getItem(itemTag); 33 | 34 | return this.has(item); 35 | } 36 | 37 | has(item: Item): boolean { 38 | let i = this.items.indexOf(item); 39 | return i > -1; 40 | } 41 | 42 | removeIndex(i: number): boolean { 43 | if (i >= this.length) { 44 | return false; 45 | } 46 | 47 | this.items[i] = null; 48 | 49 | return true; 50 | } 51 | 52 | remove(item: Item): boolean { 53 | let i = this.items.indexOf(item); 54 | if (i == -1) { 55 | return false; 56 | } 57 | 58 | return this.removeIndex(i); 59 | } 60 | 61 | removeItem(itemTag: string): boolean { 62 | let item = this.story.getItem(itemTag); 63 | 64 | return this.remove(item); 65 | } 66 | } -------------------------------------------------------------------------------- /src/Item.ts: -------------------------------------------------------------------------------- 1 | export default interface Item { 2 | tag: string, 3 | name: string, 4 | x: number, 5 | y: number 6 | } -------------------------------------------------------------------------------- /src/ItemComponent.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import Item from "./Item"; 3 | import { observer } from "mobx-react"; 4 | import Tooltip from "./Tooltip"; 5 | 6 | interface ItemComponentProps { 7 | item: Item; 8 | onClick?: () => void; 9 | onDragStart?: () => void; 10 | onDrop?: () => void; 11 | draggable?: boolean; 12 | } 13 | 14 | @observer 15 | export default class ItemComponent extends React.Component { 16 | readonly SIZE_ITEM = 32; 17 | 18 | static defaultProps: Partial = { 19 | draggable: true 20 | }; 21 | 22 | render() { 23 | return ( 24 |
this.onDragOver(e) : null} 27 | onDrop={this.props.item == null ? e => this.onDrop(e) : null} 28 | > 29 | {this.props.item != null && ( 30 | 33 | {this.props.item.name} 34 |
35 | } 36 | > 37 |
this.onDragStart(e)} 40 | onClick={this.props.onClick} 41 | style={{backgroundPosition: this.getBackgroundPosition()}} 42 | > 43 |
44 | 45 | )} 46 | 47 | ); 48 | } 49 | 50 | getBackgroundPosition(): string { 51 | return (-this.props.item.x * this.SIZE_ITEM) + "px " + (-this.props.item.y * this.SIZE_ITEM) + "px"; 52 | } 53 | 54 | onDragStart(e: React.DragEvent) { 55 | e.dataTransfer.dropEffect = "move"; 56 | 57 | let div = document.createElement("div"); 58 | div.className = "item"; 59 | div.style.backgroundPosition = this.getBackgroundPosition(); 60 | div.style.display = "none"; 61 | 62 | document.getElementsByTagName("body")[0].appendChild(div); 63 | e.dataTransfer.setDragImage(div, 16, 16); 64 | 65 | if (this.props.onDragStart != null) { 66 | this.props.onDragStart(); 67 | } 68 | } 69 | 70 | onDragOver(e: React.DragEvent) { 71 | e.preventDefault(); 72 | e.dataTransfer.dropEffect = "move"; 73 | } 74 | 75 | onDrop(e: React.DragEvent) { 76 | e.preventDefault(); 77 | 78 | if (this.props.onDrop != null) { 79 | this.props.onDrop(); 80 | } 81 | } 82 | } -------------------------------------------------------------------------------- /src/ItemDragDataTransfer.ts: -------------------------------------------------------------------------------- 1 | import Inventory from "./Inventory"; 2 | 3 | /** 4 | * This class is used to handle the data transported 5 | * during a drag-n-drop operation of an item 6 | */ 7 | export default class ItemDragDataTransfer { 8 | static i: ItemDragDataTransfer; 9 | 10 | private constructor( 11 | public inventory: Inventory, 12 | public i: number 13 | ) { 14 | ItemDragDataTransfer.i = this; 15 | } 16 | 17 | static create(inventory: Inventory, i: number) { 18 | new ItemDragDataTransfer(inventory, i); 19 | } 20 | } -------------------------------------------------------------------------------- /src/Passage.ts: -------------------------------------------------------------------------------- 1 | import Story from "./Story"; 2 | 3 | export default class Passage { 4 | readonly THEME_PREFIX = "theme-"; 5 | 6 | constructor( 7 | public pid: number, 8 | public name: string, 9 | public tags: string[], 10 | public content: string 11 | ) { 12 | 13 | } 14 | 15 | getTheme(defaultTheme: string): string { 16 | return Story.PANEL_THEMES.reduce((p, c) => this.tags.indexOf(this.THEME_PREFIX + c) != -1 ? c : p, defaultTheme); 17 | } 18 | } -------------------------------------------------------------------------------- /src/Shop.ts: -------------------------------------------------------------------------------- 1 | import { observable } from 'mobx'; 2 | import Item from "./Item"; 3 | import Inventory from "./Inventory"; 4 | import Story from "./Story"; 5 | 6 | export class Shop { 7 | // Internally, a shop still has an inventory 8 | inventory: Inventory; 9 | entries: ShopEntry[] = []; 10 | 11 | constructor(public story: Story) { 12 | this.inventory = new Inventory(story, 1000); 13 | } 14 | 15 | addItem(item: Item, price: number) { 16 | if (this.entries.length >= this.inventory.length) { 17 | throw new Error(`"addItem": Can't have more than ${this.inventory.length} items in the shop`); 18 | } 19 | 20 | this.inventory.add(item); 21 | this.entries.push(new ShopEntry(item, price)); 22 | } 23 | 24 | buy(entry: ShopEntry): boolean { 25 | if (!this.canBuy(entry)) { 26 | return false; 27 | } 28 | 29 | entry.bought = true; 30 | this.story.character.removeGold(entry.price); 31 | 32 | return true; 33 | } 34 | 35 | canBuy(entry: ShopEntry): boolean { 36 | return this.story.character.gold >= entry.price && !entry.bought; 37 | } 38 | } 39 | 40 | export class ShopEntry { 41 | @observable bought: boolean = false; 42 | 43 | constructor(public item: Item, public price: number) { 44 | 45 | } 46 | } -------------------------------------------------------------------------------- /src/Stat.ts: -------------------------------------------------------------------------------- 1 | export interface Stat { 2 | name: string 3 | } -------------------------------------------------------------------------------- /src/Story.ts: -------------------------------------------------------------------------------- 1 | import { observable } from 'mobx'; 2 | import { Shop, ShopEntry } from './Shop'; 3 | import { Stat } from './Stat'; 4 | 5 | import Inventory from "./Inventory"; 6 | import Choice from "./Choice"; 7 | import Character from "./Character"; 8 | import Passage from "./Passage"; 9 | import Item from "./Item"; 10 | import * as _ from "lodash"; 11 | import * as marked from "marked"; 12 | import StoryConfig from "./StoryConfig"; 13 | 14 | export default class Story { 15 | static readonly PANEL_THEMES = [ 16 | "rock", "rock-beige", "rock-light", "rock-dark", 17 | "frame-bronze", "frame-light", "frame-gold", "frame-dark", 18 | "parchment", 19 | "metal-blue", "metal-red", "metal-green", "metal-yellow", 20 | "blue", "red", "green", "yellow", "black", "chest" 21 | ]; 22 | 23 | character: Character; 24 | 25 | /** 26 | * Contains the passages traversed by the player through 27 | * its playthrough 28 | */ 29 | history: Passage[] = []; 30 | 31 | @observable currentPassage: Passage; 32 | @observable rendereredText: string; 33 | @observable choices: Choice[]; 34 | @observable lootableInventory: Inventory; 35 | @observable error: string = null; 36 | @observable shop: Shop; 37 | 38 | get items(): Item[] { 39 | return this.config.items; 40 | } 41 | 42 | get stats(): Stat[] { 43 | return this.config.stats; 44 | } 45 | 46 | constructor( 47 | public name: string, 48 | public config: StoryConfig, 49 | public passages: Passage[], 50 | public startPassage: Passage, 51 | public deadPassage: Passage 52 | ) { 53 | 54 | } 55 | 56 | /** 57 | * Initialize the game and launch the ´startPassage´ 58 | */ 59 | start() { 60 | this.character = new Character(this); 61 | 62 | // Expose some variables to the writer 63 | window.story = this; 64 | window.character = this.character; 65 | 66 | this.showPassage(this.startPassage); 67 | } 68 | 69 | choose(choice: Choice) { 70 | if (this.choices.indexOf(choice) == -1) { 71 | return; 72 | } 73 | 74 | this.showPassage(choice.passage); 75 | } 76 | 77 | /** 78 | * To be called by the writer's scripts 79 | * @param itemTag 80 | */ 81 | addLootItem(itemTag: string): boolean { 82 | let item = this.getItem(itemTag); 83 | 84 | return this.addLootItemItem(item); 85 | } 86 | 87 | addShopItem(itemTag: string, price: number) { 88 | let item = this.getItem(itemTag); 89 | 90 | this.addShopItemItem(item, price); 91 | } 92 | 93 | getItem(itemTag: string): Item { 94 | let item = this.items.filter(i => i.tag == itemTag)[0]; 95 | if (item == null) { 96 | throw new Error("Couldn't find item with tag \"" + itemTag + "\""); 97 | } 98 | 99 | return item; 100 | } 101 | 102 | /** 103 | * See @{Story#showPassage} 104 | * @param name Name of the passage to show 105 | */ 106 | show(name: string) { 107 | let passage = this.passages.filter(p => p.name == name)[0]; 108 | if (passage == null) { 109 | throw new Error("Couldn't find passage with name \"" + name + "\""); 110 | } 111 | 112 | this.showPassage(passage); 113 | } 114 | 115 | getStat(tag: string): Stat { 116 | let stat = this.stats.filter(s => s.name == tag)[0]; 117 | if (stat == null) { 118 | throw new Error(`Couldn't find stat with name "${tag}"`); 119 | } 120 | 121 | return stat; 122 | } 123 | 124 | /** 125 | * If not already existing, create a loot chest and add 126 | * an `item` inside it. 127 | * @param item `Item` to add in the chest 128 | */ 129 | private addLootItemItem(item: Item): boolean { 130 | if (this.lootableInventory == null) { 131 | this.lootableInventory = new Inventory(this, 6); 132 | } 133 | 134 | return this.lootableInventory.add(item); 135 | } 136 | 137 | private addShopItemItem(item: Item, price: number) { 138 | if (this.shop == null) { 139 | this.shop = new Shop(this); 140 | } 141 | 142 | return this.shop.addItem(item, price); 143 | } 144 | 145 | /** 146 | * Pass the current passage and launch the Passage `passage`. 147 | * This will also destroy loot & shops in the current passage. 148 | * @param passage The passage to show. 149 | */ 150 | private showPassage(passage: Passage) { 151 | // We flush the loot and things 152 | this.lootableInventory = null; 153 | this.shop = null; 154 | 155 | // First pass through lodash's template to execute 156 | // the inlined javascript code 157 | let processedText; 158 | try { 159 | processedText = _.template(passage.content)(); 160 | } catch (e) { 161 | this.error = e.message; 162 | return; 163 | } 164 | 165 | let linkRegex = /\[\[(.+?)((->|\|)(.+?))?\]\]/gi; 166 | let choices: Choice[]; 167 | 168 | // If the player is dead, we don't parse links ... 169 | // if it is not already the death passage. 170 | if (!this.character.dead || passage == this.deadPassage) { 171 | // Parsing links `[[...]]` 172 | // First, we find them 173 | choices = []; 174 | let currentMatch: RegExpExecArray; 175 | while ((currentMatch = linkRegex.exec(processedText)) != null) { 176 | console.log(currentMatch); 177 | // We first check if it is a link with 2 parts 178 | let name: string; 179 | let text: string; 180 | if (currentMatch[2] != null) { 181 | // 2 parts 182 | name = currentMatch[4]; 183 | text = currentMatch[1]; 184 | } else{ 185 | // 1 part 186 | name = currentMatch[1]; 187 | text = name; 188 | } 189 | 190 | let passage = this.passages.filter(p => p.name == name)[0]; 191 | 192 | if (passage != null) { 193 | choices.push(new Choice(passage, marked(text))); 194 | } else { 195 | // The writer might have put a wrong passage name in the link, 196 | // we ignore it if it is the case 197 | // TODO: Display an error or something 198 | this.error = `Couldn't find any passage with the name "${name}"`; 199 | } 200 | } 201 | } else { 202 | // ...and we add a single choice that will lead to `deathPassage` 203 | choices = [ 204 | new Choice( 205 | this.deadPassage, 206 | this.config.deadMessage 207 | ) 208 | ]; 209 | } 210 | this.choices = choices; 211 | 212 | // Rremove the parsed links from the text 213 | processedText = processedText.replace(linkRegex, ""); 214 | 215 | // Parse & transform the markdown 216 | this.rendereredText = marked(processedText); 217 | this.currentPassage = passage; 218 | 219 | // Add the new passage to the history 220 | this.history.push(passage); 221 | } 222 | } -------------------------------------------------------------------------------- /src/StoryConfig.ts: -------------------------------------------------------------------------------- 1 | import { Stat } from './Stat'; 2 | import Item from "./Item"; 3 | 4 | export default interface StoryConfig { 5 | characterTheme: string, 6 | chestTheme: string, 7 | passageTheme: string, 8 | buttonTheme: string, 9 | debug: boolean, 10 | items: Item[], 11 | stats: Stat[], 12 | deadMessage: string, 13 | deathPassage: string, 14 | enableHealth: boolean, 15 | enableGold: boolean, 16 | enableStats: boolean, 17 | displayCharacterPanel: boolean 18 | } -------------------------------------------------------------------------------- /src/Tooltip.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { observer } from "mobx-react"; 3 | import { observable } from "mobx/lib/mobx"; 4 | import * as CSSTransition from "react-transition-group/CSSTransition"; 5 | 6 | interface TooltipProps { 7 | tooltip: React.ReactNode 8 | } 9 | 10 | @observer 11 | export default class Tooltip extends React.Component { 12 | readonly OFFSET = 20; 13 | 14 | @observable seeable: boolean = false; 15 | @observable x: number; 16 | @observable y: number; 17 | 18 | render() { 19 | return ( 20 |
this.onMouseEnter(e)} 22 | onMouseOut={e => this.onMouseOut(e)} 23 | onMouseMove={e => this.onMouseMove(e)} 24 | > 25 | {this.props.children} 26 | {this.seeable && ( 27 | 32 |
40 | {this.props.tooltip} 41 |
42 |
43 | )} 44 |
45 | ); 46 | } 47 | 48 | onMouseEnter(e: React.MouseEvent) { 49 | this.seeable = true; 50 | this.updatePosition(e); 51 | } 52 | 53 | onMouseMove(e: React.MouseEvent) { 54 | if (this.seeable) { 55 | this.updatePosition(e); 56 | } 57 | } 58 | 59 | onMouseOut(e: React.MouseEvent) { 60 | this.seeable = false; 61 | } 62 | 63 | private updatePosition(e: React.MouseEvent) { 64 | this.x = e.clientX; 65 | this.y = e.clientY 66 | } 67 | } -------------------------------------------------------------------------------- /src/defaultItems.ts: -------------------------------------------------------------------------------- 1 | import Item from "./Item"; 2 | 3 | let items: Item[] = [ 4 | // Weapons 5 | { 6 | tag: "dagger", 7 | name: "Dagger", 8 | x: 15, y: 96 9 | }, 10 | { 11 | tag: "fire-dagger", 12 | name: "Fire Dagger", 13 | x: 0, y: 97 14 | }, 15 | { 16 | tag: "bow", 17 | name: "Bow", 18 | x: 3, y: 93 19 | }, 20 | { 21 | tag: "fire-bow", 22 | name: "Fire Bow", 23 | x: 4, y: 93 24 | }, 25 | { 26 | tag: "axe", 27 | name: "Axe", 28 | x: 5, y: 91 29 | }, 30 | { 31 | tag: "fire-axe", 32 | name: "Fire Axe", 33 | x: 6, y: 91 34 | }, 35 | { 36 | tag: "scepter", 37 | name: "Scepter", 38 | x: 15, y: 103 39 | }, 40 | { 41 | tag: "fire-scepter", 42 | name: "Fire Scepter", 43 | x: 0, y: 104 44 | }, 45 | { 46 | tag: "sword", 47 | name: "Sword", 48 | x: 1, y: 107 49 | }, 50 | { 51 | tag: "fire-sword", 52 | name: "Fire Sword", 53 | x: 2, y: 107 54 | }, 55 | { 56 | tag: "shield", 57 | name: "Shield", 58 | x: 2, y: 71 59 | }, 60 | { 61 | tag: "wooden-shield", 62 | name: "Wooden Shield", 63 | x: 0, y: 71 64 | }, 65 | { 66 | tag: "pavise-shield", 67 | name: "Pavise Shield", 68 | x: 5, y: 71 69 | }, 70 | { 71 | tag: "targe", 72 | name: "Targe", 73 | x: 3, y: 71 74 | }, 75 | { 76 | tag: "holy-shield", 77 | name: "Holy Shield", 78 | x: 11, y: 71 79 | }, 80 | { 81 | tag: "golden-shield", 82 | name: "Golden Shield", 83 | x: 14, y: 71 84 | }, 85 | // Potions 86 | { 87 | tag: "small-red-potion", 88 | name: "Small Red potion", 89 | x: 2, y: 45 90 | }, 91 | { 92 | tag: "red-potion", 93 | name: "Red potion", 94 | x: 3, y: 48 95 | }, 96 | { 97 | tag: "big-red-potion", 98 | name: "Big Red potion", 99 | x: 14, y: 36 100 | }, 101 | { 102 | tag: "small-green-potion", 103 | name: "Small Green potion", 104 | x: 5, y: 45 105 | }, 106 | { 107 | tag: "green-potion", 108 | name: "Green potion", 109 | x: 6, y: 48 110 | }, 111 | { 112 | tag: "big-green-potion", 113 | name: "Big Green potion", 114 | x: 1, y: 37 115 | }, 116 | // Other 117 | { 118 | tag: "feather", 119 | name: "Feather", 120 | x: 5, y: 13 121 | }, 122 | { 123 | tag: "bat-wing", 124 | name: "Bat Wing", 125 | x: 12, y: 12 126 | }, 127 | { 128 | tag: "bird-beak", 129 | name: "Bird Beak", 130 | x: 13, y: 12 131 | }, 132 | { 133 | tag: "long-bird-beak", 134 | name: "Long Bird Beak", 135 | x: 13, y: 13 136 | }, 137 | { 138 | tag: "book", 139 | name: "Book", 140 | x: 14, y: 61 141 | }, 142 | { 143 | tag: "map", 144 | name: "Map", 145 | x: 11, y: 63 146 | }, 147 | { 148 | tag: "scroll", 149 | name: "Scroll", 150 | x: 14, y: 62 151 | }, 152 | ]; 153 | 154 | export default items; -------------------------------------------------------------------------------- /src/defaultStoryConfig.ts: -------------------------------------------------------------------------------- 1 | import StoryConfig from "./StoryConfig"; 2 | import defaultItems from "./defaultItems"; 3 | 4 | let storyConfig: StoryConfig = { 5 | characterTheme: "tablet", 6 | chestTheme: "chest", 7 | passageTheme: "parchment", 8 | buttonTheme: "blue", 9 | debug: false, 10 | items: defaultItems, 11 | stats: [], 12 | deadMessage: "You are dead", 13 | deathPassage: null, 14 | enableHealth: true, 15 | enableGold: true, 16 | enableStats: false, 17 | displayCharacterPanel: true 18 | }; 19 | 20 | export default storyConfig; -------------------------------------------------------------------------------- /src/helper.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Returns an object that has the same schema than `template`, but with 3 | * replaced from `patch`, if they are specified 4 | * @param patch A partially-filled version of template 5 | * @param template A full-object 6 | */ 7 | export function mergeObject(patch: Partial, template: E): E { 8 | // We have to specify the type of `newObject` as `any` 9 | // because it will first be of type Partial, and then 10 | // become a full E 11 | // TODO: Find a way to statically express that 12 | let newObject: any = {}; 13 | 14 | for (let propertyKey in template) { 15 | if (patch[propertyKey] === undefined) { 16 | // `patch` doesn't provide a value for this property, 17 | // take the one from `template` 18 | newObject[propertyKey] = template[propertyKey]; 19 | } else { 20 | let templateValue = template[propertyKey]; 21 | let patchValue = patch[propertyKey]; 22 | 23 | // We check if the type matches 24 | if (template[propertyKey] != null && patchValue != null && typeof(template[propertyKey]) != typeof(patchValue)) { 25 | throw new Error(`Wrong type for "${propertyKey}", should be "${typeof(templateValue)}", but is "${typeof(patchValue)}"`); 26 | } 27 | 28 | if (templateValue == null) { 29 | newObject[propertyKey] = patchValue; 30 | } else if (typeof(templateValue) == "object" && !Array.isArray(templateValue)) { 31 | newObject[propertyKey] = mergeObject(patchValue, templateValue); 32 | } else { 33 | // TODO: handle list in a better way 34 | newObject[propertyKey] = patchValue; 35 | } 36 | } 37 | } 38 | 39 | return newObject; 40 | } -------------------------------------------------------------------------------- /src/script.tsx: -------------------------------------------------------------------------------- 1 | import { observable } from "mobx"; 2 | import { observer } from "mobx-react"; 3 | import * as React from "react"; 4 | import * as ReactDOM from "react-dom"; 5 | import * as _ from "lodash"; 6 | import Story from "./Story"; 7 | import Inventory from "./Inventory"; 8 | import Item from "./Item"; 9 | import Character from "./Character"; 10 | import Passage from "./Passage"; 11 | import * as CSSTransition from "react-transition-group/CSSTransition"; 12 | import Choice from "./Choice"; 13 | import defaultStoryConfig from "./defaultStoryConfig"; 14 | import StoryConfig from "./StoryConfig"; 15 | import { mergeObject } from "./helper"; 16 | import CharacterComponent from "./CharacterComponent"; 17 | import ItemComponent from "./ItemComponent"; 18 | import ItemDragDataTransfer from "./ItemDragDataTransfer"; 19 | import Interface from "./Interface"; 20 | 21 | /** 22 | * This interface is there to add the objects to the 23 | * `window` global object so that they can be accessed 24 | * by the writer in its scripts. 25 | */ 26 | declare global { 27 | interface Window { 28 | story: Story, 29 | character: Character 30 | config: Partial 31 | } 32 | } 33 | 34 | window.addEventListener("load", () => { 35 | // Parsing the data given by Twine (story, passages, ...) 36 | let storyElem = document.getElementsByTagName("tw-storydata")[0]; 37 | let passagesElems = storyElem.getElementsByTagName("tw-passagedata"); 38 | let scriptElems = storyElem.getElementsByTagName("script"); 39 | let styleElems = storyElem.getElementsByTagName("style"); 40 | 41 | let title = document.getElementsByTagName("title")[0].innerText; 42 | 43 | let passages: Passage[] = []; 44 | for (let i =0;i < passagesElems.length;i++) { 45 | let passageElem = passagesElems[i]; 46 | let pid = parseInt(passageElem.getAttribute("pid")); 47 | let name = passageElem.getAttribute("name"); 48 | let tags = passageElem.getAttribute("tags").split(' '); 49 | let content = _.unescape(passageElem.innerHTML); 50 | 51 | passages.push( 52 | new Passage(pid, name, tags, content) 53 | ); 54 | } 55 | 56 | let startPassagePid = parseInt(storyElem.getAttribute("startnode")); 57 | let startPassage = passages.filter(p => p.pid == startPassagePid)[0]; 58 | 59 | if (startPassage == null) { 60 | // If there's no start passage (for whatever reason), take the 61 | // one with the lowest pid 62 | startPassage = passages.reduce((p, c) => p.pid < c.pid ? p : c, passages[0]); 63 | } 64 | 65 | // Parse and activate the user-written CSS 66 | for (let i = 0;i < styleElems.length;i++) { 67 | let style = document.createElement("style"); 68 | style.type = "text/css"; 69 | style.appendChild(document.createTextNode(styleElems[i].textContent)); 70 | document.head.appendChild(style); 71 | } 72 | 73 | // Activate the user-written JS 74 | for (let i = 0;i < scriptElems.length;i++) { 75 | eval(scriptElems[i].textContent); 76 | } 77 | 78 | // Get the default config, and apply to it the config 79 | // given by the user 80 | let storyConfig = defaultStoryConfig; 81 | if (window.config != undefined) { 82 | if (typeof(window.config) != "object") { 83 | throw new Error(`window.config should be an object, but is "${typeof(window.config)}"`); 84 | } 85 | 86 | storyConfig = mergeObject(window.config, defaultStoryConfig); 87 | } 88 | 89 | // Find the death passage defined by the user or create a new one 90 | let deathPassage: Passage = null; 91 | if (storyConfig.deathPassage != null) { 92 | // Find the one created by the user 93 | deathPassage = passages.filter(p => p.name == storyConfig.deathPassage)[0]; 94 | 95 | if (deathPassage == null) { 96 | throw new Error(`config.deathPassage is set to "${deathPassage}", but no passage with this name found.`); 97 | } 98 | } else { 99 | // Create a new one 100 | deathPassage = new Passage(45, "Final passage", ["theme-red", "button-red"], "You are dead.\n\nThanks for playing, you can try again by pressing F5!"); 101 | passages.push(deathPassage); 102 | } 103 | 104 | let story = new Story(title, storyConfig, passages, startPassage, deathPassage); 105 | 106 | story.start(); 107 | 108 | ReactDOM.render(, document.getElementById("root")); 109 | }); -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "experimentalDecorators": true, 4 | "module": "commonjs", 5 | "target": "es5", 6 | "jsx": "react" 7 | } 8 | } -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const webpack = require("webpack"); 3 | const TwineFormatPlugin = require("./webpack/TwineFormatPlugin.js"); 4 | const UglifyJsPlugin = require('uglifyjs-webpack-plugin'); 5 | const ExtractTextPlugin = require("extract-text-webpack-plugin"); 6 | 7 | let modules = 8 | 9 | module.exports = [{ 10 | entry: { 11 | "code.js": "./src/script.tsx", 12 | "design.css": "./design.css" 13 | }, 14 | output: { 15 | path: __dirname, 16 | filename: "./dist/bundle-[name]" 17 | }, 18 | resolve: { 19 | extensions: [".ts", ".tsx", ".js"] 20 | }, 21 | module: { 22 | rules: [ 23 | { 24 | test: /\.tsx?$/, 25 | loader: "ts-loader", 26 | exclude: /node_modules/ 27 | }, 28 | { 29 | test: /\.css$/, 30 | loader: ExtractTextPlugin.extract({ 31 | use: "css-loader" 32 | }) 33 | } 34 | ] 35 | }, 36 | plugins: [ 37 | new ExtractTextPlugin("./dist/design.css"), new TwineFormatPlugin() 38 | ] 39 | }, 40 | { 41 | entry: "./scripts/item-table-generator.ts", 42 | output: { 43 | path: __dirname, 44 | filename: "./dist/item-table-generator" 45 | }, 46 | resolve: { 47 | extensions: [".ts", ".tsx", ".js"] 48 | }, 49 | module: { 50 | rules: [ 51 | { 52 | test: /\.tsx?$/, 53 | loader: "ts-loader", 54 | exclude: /node_modules/ 55 | } 56 | ] 57 | } 58 | } 59 | ]; -------------------------------------------------------------------------------- /webpack/TwineFormatPlugin.js: -------------------------------------------------------------------------------- 1 | var fs = require("fs"); 2 | var uglify = require("uglify-js"); 3 | const jimp = require("jimp"); 4 | 5 | function TwineFormatPlugin(options) { 6 | 7 | } 8 | 9 | TwineFormatPlugin.prototype.apply = function(compiler) { 10 | compiler.plugin('emit', (compilation, callback) => { 11 | let template = fs.readFileSync("index.html").toString(); 12 | // We use `() => ...` for the second arg of replace because otherwise, it leads 13 | // to specific behavior when encountering `$'` 14 | 15 | let designCode = compilation.assets["./dist/design.css"] != null ? compilation.assets["./dist/design.css"].source() : "null"; 16 | // Process the macros to insert base64-encoded images into the `.css` 17 | // Because `jimp` is async only, we have to do some workarounds 18 | // to make everything work. It's ugly, but it works. 19 | let base64EncodedImages = {}; 20 | let i = 0; 21 | let max = 0; 22 | designCode.replace(/\[\[(.*)\]\]/gi, (substring, argsString) => { 23 | max++; 24 | let args = argsString.split(","); 25 | jimp.read(args[0], (err, img) => { 26 | if (err != null) { 27 | console.log(err); 28 | return; 29 | } 30 | 31 | if (args.length > 1) { 32 | let x = parseInt(args[1]); 33 | let y = parseInt(args[2]); 34 | let w = parseInt(args[3]); 35 | let h = parseInt(args[4]); 36 | 37 | img.crop(x, y, w, h); 38 | } 39 | 40 | img.scale(2, jimp.RESIZE_NEAREST_NEIGHBOR); 41 | 42 | img.getBase64(jimp.MIME_PNG, (err, base64) => { 43 | if (err != null) { 44 | console.log(err); 45 | throw new Error(err); 46 | } 47 | 48 | base64EncodedImages[substring] = base64; 49 | i++; 50 | if (i == max) { 51 | // We finished encoding all images 52 | // We can now replace the macros in the css 53 | designCode = designCode.replace(/\[\[(.*)\]\]/gi, (substring, argsString) => { 54 | return base64EncodedImages[substring]; 55 | }); 56 | 57 | let source = template 58 | .replace("/*{{DESIGN}}*/", () => designCode) 59 | .replace("/*{{CODE}}*/", () => compilation.assets["./dist/bundle-code.js"] != null ? compilation.assets["./dist/bundle-code.js"].source() : ""); 60 | 61 | let options = { 62 | name: "Adventures", 63 | version: "1.1.1", 64 | author: "Longwelwind", 65 | description: "A story format to create RPG stories with health, loot, gold and more. See its documentation", 66 | proofing: false, 67 | source: source 68 | }; 69 | 70 | let formatFile = "window.storyFormat(" + JSON.stringify(options, null, 2) + ");"; 71 | compilation.assets["./dist/format.js"] = { 72 | source: () => formatFile, 73 | size: () => formatFile.length 74 | }; 75 | // Not necessary, only for debugging purposes 76 | compilation.assets["./dist/source.html"] = { 77 | source: () => source, 78 | size: () => source.length 79 | }; 80 | 81 | callback(); 82 | } 83 | }); 84 | }); 85 | }); 86 | }); 87 | }; 88 | 89 | module.exports = TwineFormatPlugin; 90 | --------------------------------------------------------------------------------