├── .editorconfig ├── .eslintrc.js ├── .gitignore ├── LocationFinder.js ├── README.md ├── abbr.js ├── about.js ├── achievements ├── AchievementManager.js ├── AchievementRenderer.js ├── AchievementStorage.js ├── achievement.css ├── story.twee └── types.ts ├── bin └── build.js ├── daymode.css ├── daymode.js ├── detectLang.js ├── dlg.js ├── faint.js ├── fontSize.js ├── fullscreen.js ├── genderswitch.js ├── hubnav.js ├── iconcheckbox.js ├── inventory.js ├── journal.js ├── l10n-ru.js ├── linkif.js ├── linkonce.js ├── menuButton.js ├── more.js ├── mute.js ├── onPassagePresent.js ├── package-lock.json ├── package.json ├── pickUnique.js ├── plurals-en.js ├── plurals-ru.js ├── plurals.js ├── preloadImages.js ├── promiseLock.js ├── qbn.js ├── rumble.js ├── scUtils.twee-config.yaml ├── skipIntro.js ├── sugarcube.d.ts ├── switchLangBtn.js └── volumeButtons.js /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = false 6 | charset = utf-8 7 | indent_style = space 8 | indent_size = 4 9 | trim_trailing_whitespace = true 10 | 11 | [package.json] 12 | indent_style = space 13 | indent_size = 2 -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /* eslint-env node */ 4 | 5 | module.exports = { 6 | parser: 'babel-eslint', 7 | env: { 8 | 'browser': true, 9 | 'es6': true, 10 | 'jquery': true, 11 | }, 12 | 'extends': 'eslint:recommended', 13 | 'rules': { 14 | 'indent': ['error', 4], 15 | 'linebreak-style': ['error', 'unix'], 16 | 'quotes': ['error', 'single'], 17 | 'semi': ['error', 'always'], 18 | 'comma-dangle': ['error', 'always-multiline'], 19 | 'no-console': ['off'], 20 | 'no-extra-semi': ['off'], 21 | }, 22 | }; -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | bundle.js -------------------------------------------------------------------------------- /LocationFinder.js: -------------------------------------------------------------------------------- 1 | (function LocationFinder() { 2 | 'use strict'; 3 | 4 | /* globals Story, Config, State */ 5 | 6 | class LocationFinder { 7 | constructor(onChange = null, classNamePrefix = 'location-', eventHandlers = null) { 8 | this.markers = []; 9 | 10 | // we don't really do lookUp, but it's the only way to iterate over all passages 11 | Story.lookupWith((passage) => { 12 | const marker = LocationFinder.extractLocations(passage.tags); 13 | 14 | if (marker) { 15 | this.markers.push(marker); 16 | } 17 | }); 18 | 19 | if (Config.debug) { 20 | console.info( 21 | `Locations detected: 22 | ${this.markers.map((marker) => `\t${marker}`).join('\n')}` 23 | ); 24 | } 25 | 26 | this.latestLocation = this.detectLocation(); 27 | 28 | this.$doc = jQuery(document); 29 | this.$html = jQuery(document.documentElement); 30 | 31 | this.onChange = onChange; 32 | this.classNamePrefix = classNamePrefix; 33 | 34 | this._listenPassageStart(); 35 | 36 | if (eventHandlers) { 37 | this._processHandlers(eventHandlers); 38 | } 39 | 40 | this._toggleHtmlClass(this.latestLocation); 41 | } 42 | 43 | _processHandlers(eventHandlers) { 44 | Object.keys(eventHandlers).forEach((eventName) => { 45 | this.$doc.on(eventName, (event) => { 46 | eventHandlers[eventName](this.detectLocation(), event); 47 | }); 48 | }); 49 | } 50 | 51 | _onChange(newLocation, oldLocation) { 52 | if (Config.debug) { 53 | console.info(`Location "${oldLocation}" changed to "${newLocation}"`); 54 | } 55 | 56 | if (this.onChange) { 57 | this.onChange(newLocation, oldLocation); 58 | } 59 | 60 | this._toggleHtmlClass(newLocation, oldLocation); 61 | } 62 | 63 | _listenPassageStart() { 64 | this.$doc.on(':passagestart', () => { 65 | const newLocation = this.detectLocation(); 66 | 67 | if (newLocation !== this.latestLocation) { 68 | this._onChange(newLocation, this.latestLocation); 69 | this.latestLocation = newLocation; 70 | }; 71 | }); 72 | } 73 | 74 | _toggleHtmlClass(newLocation, oldLocation) { 75 | if (this.classNamePrefix) { 76 | this.$html 77 | .removeClass(this.classNamePrefix + oldLocation) 78 | .addClass(this.classNamePrefix + newLocation); 79 | } 80 | } 81 | 82 | /** 83 | * Iterates over history detecting latest visited location, or defaults to the first one found in story. 84 | * 85 | * @returns {string} 86 | */ 87 | detectLocation() { 88 | let counter = State.length - 1; 89 | while (counter > 0) { 90 | const moment = State.index(counter); 91 | const passage = Story.get(moment.title); 92 | for (const tag of passage.tags) { 93 | if (tag.startsWith(LocationFinder.nameToken)) { 94 | return LocationFinder.getLocationNameFromTag(tag); 95 | } 96 | } 97 | counter--; 98 | } 99 | 100 | return this.markers[0]; 101 | } 102 | 103 | /** 104 | * @param {string[]} tags 105 | * @returns {string} 106 | */ 107 | static extractLocations(tags) { 108 | const locationTag = tags.find(tag => tag.startsWith(LocationFinder.nameToken)); 109 | 110 | if (locationTag) { 111 | return LocationFinder.getLocationNameFromTag(locationTag); 112 | } 113 | } 114 | 115 | /** 116 | * @param {string} tag 117 | * @return {string} 118 | */ 119 | static getLocationNameFromTag(tag) { 120 | return tag.substring(LocationFinder.nameToken.length); 121 | } 122 | 123 | static get nameToken() { 124 | return 'locationName-'; 125 | } 126 | } 127 | 128 | window.scUtils = Object.assign( 129 | window.scUtils || {}, 130 | { 131 | LocationFinder, 132 | } 133 | ); 134 | }()); -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Collection of [SugarCube 2](http://www.motoslave.net/sugarcube/2/) macros and goodies 2 | 3 | Please note that code will not work in older browsers (Internet Explorer, pre-Chromium Edge, etc) as-is. See [Using the code](#using-the-code) section below. 4 | 5 | If you use [Twee 3 Language Tools](https://github.com/cyrusfirheir/twee3-language-tools) VS Code extension, you might find [`scUtils.twee-config.yaml`](./scUtils.twee-config.yaml) useful, as it contains declarations of all macros in this repo. 6 | 7 | --- 8 | 9 | ## Installation 10 | 11 | The `*.js` files follow a general pattern of `.js` (and possibly `.css`) for each macro, and the section explaining each macro links to the corresponding file. 12 | 13 | To install: 14 | - If using the Twine desktop/web app, copy contents of the `*.js` file to `Story JavaScript`, and if relevant, contents of `*.css` file to `Story Stylesheet`. 15 | - If using a compiler like Tweego, drop the `*.js` — if relevant, `*.css` — files to your source folder. 16 | 17 | --- 18 | 19 | ## Contents 20 | 21 | ### Macros 22 | 23 | - [`<>` — `<>`](#anchor-more) 24 | - [`<>` — `<>` — `<>`](#anchor-dlg) 25 | - [`<>` — `<>`](#anchor-gender) 26 | - [`<>`](#anchor-hubnav) 27 | - [`<>`](#anchor-iconcheck) 28 | - [`<>` — `<>` — `<>`](#anchor-journal) 29 | - [`<>`](#anchor-linkif) 30 | - [`<>`](#anchor-linkonce) 31 | - [`<>`](#anchor-rumble) 32 | 33 | ### Goodies 34 | 35 | - [`Achievements`](#anchor-achievments) 36 | - [`menuButton.js`](#anchor-menubutton) 37 | - [`about.js`](#anchor-about) 38 | - [`daymode.js` — `daymode.css`](#anchor-daymode) 39 | - [`faint.js`](#anchor-faint) 40 | - [`fontSize.js`](#anchor-fontsize) 41 | - [`fullscreen.js`](#anchor-fullscreen) 42 | - [`LocationFinder.js`](#anchor-locationfinder) 43 | - [`mute.js`](#anchor-mute) 44 | - [`pickUnique.js`](#anchor-pickunique) 45 | - [`plurals-en.js` — `plurals-ru.js`](#anchor-plurals) 46 | - [`preloadImages.js`](#anchor-preloadimages) 47 | - [`promiseLock.js`](#anchor-promiselock) 48 | - [`qbn.js`](#anchor-qbn) 49 | - [`skipIntro.js`](#anchor-skipintro) 50 | - [`volumeButtons.js`](#anchor-volumebuttons) 51 | 52 | --- 53 | 54 | ## Macros 55 | 56 | Most macros include built-in styles created by JS, so you can copy-paste one file instead of two. Styles are as unobtrusive and neutral as possible. 57 | 58 | --- 59 | 60 | 61 | 62 | ### `<>` and `<>text<>` 63 | 64 | Source: [./more.js](./more.js) 65 | 66 | **NB: `<>` is deprecated, use `<>`.** 67 | 68 | `<>` shows tooltip — on mouse hover on desktop and on tap on touch screen. It tries to contain the tooltip entirely on the screen. `<>` can include SugarCube code inside it, but not in tooltip content. 69 | 70 | --- 71 | 72 | 73 | 74 | ### `<>`, `<>` and `<>`. 75 | 76 | Source: [./dlg.js](./dlg.js) 77 | 78 | `<>` is split into `<>`s, which, in turn, consist of `<>`s. It will show the player all lines from given level. After player chooses a line, line's content gets appended and next level is displayed. 79 | 80 | ``` 81 | ::Start 82 | — Hi! 83 | 84 | <>\ 85 | <>\ 86 | <> 87 | — Hi, how are you? 88 | — Everything's great! You? 89 | <> 90 | <> 91 | — Hi! 92 | — How are you? 93 | <> 94 | <>\ 95 | <>\ 96 | <> 97 | — Things are tough, my pet hamster just died!:( 98 | <> 99 | <> 100 | — Things are fine, wanna hang out? 101 | <> 102 | <> 103 | — Sry, playing new game, talk later! 104 | <> 105 | <> 106 | <> 107 | ``` 108 | 109 | #### `<>` 110 | `<>` accepts 3 optional arguments: 111 | * dialogue id (any unique string): needed only if there are several dialogues in the same passage. 112 | * starting level (number): if you need to skip introductions. Defaults to 0. 113 | * prefix (any string): will be prepended to each line. Empty string by default. 114 | 115 | Additionally, the macro behavior can be fine-tuned by changing the options passed into the script: `}({trim: false, append: false}));`. 116 | Setting `trim` to `true` will force `<>` to trim its contents before displaying (no unneeded line breaks). 117 | Setting `prepend` to `true` will make `<>` to prepend this line's visible part to the contents before displaying. There will be linebreak between the visible part and the contents. 118 | 119 | #### `<>` 120 | `<>` accepts single numeric mandatory argument: it's level. 121 | 122 | #### `<>` 123 | `<>` to show after this line, defaults to next level. 127 | `<>` is a container macro; it's content is displayed to player after they choose given line. 128 | 129 | Use variables and `<>`s to create branching. 130 | 131 | --- 132 | 133 | 134 | 135 | ### `<>` and `<>` 136 | 137 | Source: [./genderswitch.js](./genderswitch.js) 138 | 139 | English grammar is pretty neutral when it comes to gender, but other languages are less forgiving. For instance, in Slavic languages you need to put adjectives and past tense verbs in proper grammatical gender. 140 | 141 | `<>` displays link-like text which user can click to switch between genders: 142 | `My name is <> Watson.`. Please note that you need to declare `$isFemale` variable in `StoryInit`. 143 | 144 | `<>` chooses text between female and male version: `Your father says: My dear <>!`. `<>` assigns `gender-f` and `gender-m` classes to `html` element, so `<>` displays changes reactively, and you can use these classes to further customise game look should you need that. 145 | 146 | This can also be used to tell a story from perspectives of two persons regardless of grammatical gender. 147 | 148 | --- 149 | 150 | 151 | 152 | ### `<>` 153 | 154 | Source: [./hubnav.js](./hubnav.js) 155 | 156 | Easy navigation for set of interconnected locations. Imagine house consisting of bedroom, bathroom, living room, garage and kitchen. Each room has links to other ones but not on itself. Throw in some links that are displayed conditionally and there's whole mess on your hands. `<>` to the rescue! 157 | ``` 158 | ::_home navigation 159 | <> 167 | 168 | 169 | ::Bedroom 170 | Spacious bedroom. 171 | <> 172 | 173 | ::Bathroom 174 | Take a shower to gain energy. Take a bath to calm down. 175 | <> 176 | 177 | ::Living room 178 | Your collection of game consoles and huge flatscreen TV. 179 | <> 180 | 181 | ::Garage 182 | You bought this Vespa trading in retro games. 183 | <> 184 | 185 | ::Kitchen 186 | Clean, squeky clean, operating room clean. 187 | <> 188 | 189 | ::Back yard 190 | Daylight keeps vampires at bay. You don't go here at night. 191 | <> 192 | ``` 193 | 194 | Now it's easy to add/delete/rename rooms and change conditions. 195 | 196 | #### Tag-based navigation 197 | 198 | Even easier option is to pass a string as single argument. `<>` will build navigation based on passages marked with given tag. You can't display link conditionally in this case. 199 | 200 | ``` 201 | ::Bedroom [house] 202 | Spacious bedroom. 203 | 204 | ::Bathroom [house] 205 | Take a shower to gain energy. Take a bath to calm down. 206 | 207 | ::Living room [house] 208 | Your collection of game consoles and huge flatscreen TV. 209 | 210 | ::Garage [house] 211 | You bought this Vespa trading in retro games. 212 | 213 | ::Kitchen [house] 214 | Clean, squeky clean, operating room clean. 215 | ``` 216 | 217 | --- 218 | 219 | 220 | 221 | ### `<>` 222 | 223 | Source: [./iconcheckbox.js](./iconcheckbox.js) 224 | 225 | Default checkboxes can look ugly and not fit into overall visual style. So `<>` displays neat switch icon in the same style as built-in SugarCube controls. 226 | The simplest form is `<>toggle value<>`, this will display same label no matter what the value is. 227 | Most flexible form looks like this and allows you to run some callback when value changes: 228 | ``` 229 | <><> 231 | ``` 232 | 233 | --- 234 | 235 | 236 | 237 | ### `<>`, `<>` and `<>` 238 | 239 | Source: [./journal.js](./journal.js) 240 | 241 | Journals/logs/notes/codexes are found in many games. 242 | 243 | ``` 244 | <>Lives on North Pole (this is journal entry content)<> 245 | <>Has 4 reindeers<> 246 | <>Gift giver (this serves as optional title)<> 247 | 248 | 249 | <>Doesn't exist!!!<> 250 | <><> 251 | 252 | 253 | <>(Nothing will be shown when journaldisplay called)<> 254 | 255 | 256 | All arguments are optional and defaults to empty strings 257 | <>Have all journal entries in one place<> 258 | <><> 259 | 260 | Entries content gets rendered when <> is used, not when they are added: 261 | <> 262 | <>I like $melike!<> 263 | <> 264 | <>Me<> 265 | 266 | ``` 267 | 268 | This can also serve as a simple inventory system. 269 | 270 | --- 271 | 272 | 273 | 274 | ### ``<>`` 275 | 276 | Source: [./linkif.js](./linkif.js) 277 | 278 | Functionally identical to native [`<>`](http://www.motoslave.net/sugarcube/2/docs/#macros-macro-link), except it's only clickable if second argument is truthy, and just shows plain text otherwise. 279 | 280 | ``` 281 | ::Room 282 | There's closed <><>. There's also <><>. 283 | 284 | ::Cupboard 285 | There's key here! 286 | <> 287 | <> 288 | ``` 289 | 290 | Supports any **wiki** form of `[[link|Passage]]`. 291 | 292 | --- 293 | 294 | 295 | 296 | ### `<>` 297 | 298 | Source: [./linkonce.js](./linkonce.js) 299 | 300 | Functionally identical to ``<>``: shows link if only given passage haven't been visited in current playthrough. Supports any **wiki** form of `[[link|Passage]]`. 301 | 302 | --- 303 | 304 | 305 | 306 | ### `<>` 307 | 308 | Source: [./rumble.js](./rumble.js) 309 | 310 | Makes your device vibrate, and does nothing if browser/device doesn't [support](https://caniuse.com/#feat=vibration) [Vibration API](https://developer.mozilla.org/docs/Web/API/Navigator/vibrate). Please keep in mind that support is spotty (mostly Android and iOS Chrome and Firefox, no gamepads at all), and long vibrations or sequences can be chopped or dropped entirely, so don't rely on it to convey critical parts of story. Also be polite and provide players with means to turn it off completely. 311 | 312 | ``` 313 | <> 314 | <> 315 | <> or <> 316 | ``` 317 | 318 | Some browsers require user interaction to vibrate, so you'll probably need to wrap this in `<>`. 319 | 320 | --- 321 | 322 | ## Goodies 323 | 324 | Most goodies/utils put functions into `window.scUtils` "namespace". Things that create buttons in UIBar rely on `menuButton.js`, so include it in your script before. 325 | 326 | --- 327 | 328 | 329 | 330 | ### Achievements 331 | 332 | Source: [./achievements](./achievements) 333 | 334 | It's not that difficult to create an achievement system, but good achievement system includes many moving parts. 335 | scUtils' `achievements` is capable: 336 | 337 | * to provide achievements with title, description, unlock date and flexible checks; 338 | * to have hidden achievements 339 | * to store unlocked achievement between playthroughs (not portable between devices) 340 | * to display achievements as a floating notification in the right bottom corner of player's screen 341 | * to add a button to the sidebar, which displays a dialog containing list of achievements 342 | 343 | Take a look inside the [`./achievements/story.twee`](./achievements/story.twee) to learn how to integrate it in your game. 344 | 345 | --- 346 | 347 | 348 | 349 | ### `menuButton.js` 350 | 351 | Source: [./menuButton.js](./menuButton.js) 352 | 353 | Provide `scUtils.createPassageButton(label, iconContent, passageName)` and `scUtils.createHandlerButton(label, iconContent, shortName, handler)` functions. First one creates button which displays dialogue window displaying some passage content. 354 | 355 | --- 356 | 357 | 358 | 359 | ### `about.js` 360 | 361 | Source: [./about.js](./about.js) (relies on menuButton.js) 362 | 363 | If story have passage titled `StoryAbout`, adds "About" button which displays dialogue window with this passage rendered inside. Good for providing links to your website/patreon and attributing used assets. To change button label, assign value to correspondent l10n key:`l10nStrings.uiBarAbout = 'Who made this wonderful game?'` 364 | 365 | --- 366 | 367 | 368 | 369 | ### `daymode.js` and `daymode.css` 370 | 371 | Source: [./daymode.js](./daymode.js) and [./daymode.css](./daymode.css) (relies on menuButton.js) 372 | 373 | Depending on players reading habits, level of fatigue, device, environment and other things author can't predict it can be strainous for eyes to read both white on black or black on white. So let them invert theme when they see fit. Daymode switches your game between default (white on black) theme and (slightly adapted) official but not included bleached.css. 374 | 375 | --- 376 | 377 | 378 | 379 | ### `faint.js` 380 | 381 | Source: [./faint.js](./faint.js) 382 | 383 | Exposes `scUtils.faint(callback, duration, color, blur)` function, which fills screen with solid `color`, `blur`ring content at the same time and calls `callback` after `duration` seconds. Default values are `faint(callback = null, duration = 5, color = 'black', blur = true)`. Keep in mind that not all browsers support this blurring. 384 | 385 | Useful for emulating loosing conscience, teleportation, extended periods of time passing, etc. 386 | 387 | --- 388 | 389 | 390 | 391 | ### `fontSize.js` 392 | 393 | Source: [./fontSize.js](./fontSize.js) (relies on menuButton.js) 394 | 395 | Exposes `scUtils.createFontSizeBtn` function. When called, this function creates buttons in the sidebar to increase/decrease font size. To change button label, assign value to correspondent l10n key:`l10nStrings.uiFontSize = 'Zoom in/out'` (defaults to 'Font size'). 396 | 397 | --- 398 | 399 | 400 | 401 | ### `fullscreen.js` 402 | 403 | Source: [./fullscreen.js](./fullscreen.js) (relies on menuButton.js) 404 | 405 | Adds "Full screen" button switch to UIBar (if browser supports this API). Supposedly increases immersion. To change button label, assign value to correspondent l10n key:`l10nStrings.uiFullScreen = 'Immersive mode'`. 406 | 407 | --- 408 | 409 | 410 | 411 | ### `LocationFinder.js` 412 | 413 | Source: [./LocationFinder.js](./LocationFinder.js) 414 | 415 | Game can consist of different locations. Suppose you want to change some styles and switch background music depending on whether player is in a dungeon, forest or desert. Using vanilla SugarCube you'll need to assign designated tag to every passage in each location (and 100 passages is not a very big game). Now add music to equation and remember that player can save/load and use checkpoints. 416 | 417 | `scUtils.LocationFinder` tries to solve this issue. 418 | 0. Tag passages where player enters new locations with `locationName-desert`, `locationName-forest`, and so on. 419 | 0. Call `window.finder = new scUtils.LocationFinder(onChange, 'location-', passageEvents)` in `StoryInit` or in game JavaScript. 420 | 0. `onChange` is an optional handler which will be called each time location changes, and is passed `newLocation` and `oldLocation` arguments. You can pass `null` if you don't need this. 421 | 0. `'location-'` is an optional prefix to CSS class which will be assigned to `html` according to current location. If you don't need this behavior, pass `null` instead. 422 | 0. `passageEvents` is an optional object, mapping [passage events](http://www.motoslave.net/sugarcube/2/docs/passage-events-task-objects.html#passage-events) to handlers (`{':passagestart'(location, event) { console.log(location) }}`). Each handler will receive current location as first argument and original event as second, so you can do some pretty advanced stuff in there. 423 | 424 | **NB: Previously LocationFinder needed `locationOrder-` tags and didn't really support open-world games where player could move freely. Now LocationFinder uses history-based "location" detection, so it's not a problem anymore.** 425 | 426 | --- 427 | 428 | 429 | 430 | ### `mute.js` 431 | 432 | Source: [./mute.js](./mute.js) (relies on menuButton.js) 433 | 434 | **NB: Deprecated, use [volumeButtons.js](#volume-buttons)** 435 | 436 | Adds "Sound" button switch to UIBar, which mutes/unmutes SugarCube audio engine (note id doesn't stop playback). To change button label, assign value to correspondent l10n key:`l10nStrings.uiBarMute = 'Shut up'`. 437 | 438 | --- 439 | 440 | 441 | 442 | ### `pickUnique.js` 443 | 444 | Source: [./pickUnique.js](./pickUnique.js) 445 | 446 | [`.random()`](http://www.motoslave.net/sugarcube/2/docs/#methods-array-prototype-method-random) is a great tool, but sometimes returns same result several times in a row. These helpers solve this problem: each result is guaranteed to be different from previous 447 | ```js 448 | // No same food for two days in a row! 449 | const todayIPick = scUtils.pickUnique(['Apple pie', 'Pizza', 'Ice cream', 'Berries']); 450 | 451 | // Create a picker function 452 | const foodPicker = scUtils.createUniquePicker(['Apple pie', 'Pizza', 'Ice cream', 'Berries']); 453 | foodPicker(); // function which returns non-repeating results 454 | ``` 455 | 456 | Take a passage, split it into lines and use this lines to produce random non-repeating results. Useful if you have huge list, or the list has long lines. 457 | 458 | ``` 459 | ::Script [script] 460 | const picker = scUtils.createUniquePickerFromPassage('Foods') 461 | 462 | :: Foods 463 | Apple pie 464 | Pizza 465 | Ice cream 466 | Berries 467 | ``` 468 | 469 | --- 470 | 471 | 472 | 473 | ### `plurals-en.js` and `plurals-ru.js` 474 | 475 | Source: [./plurals.js](./plurals.js) and [./plurals-ru.js](./plurals-ru.js) 476 | 477 | While English (and most Germanic and Latin languages) only has two plural forms -- singular, and, well, plural, other languages can have more complex rules. For instance, Slavic languages have 3 forms (for 1 item, for 2..5 items, for lots of items, and they start to repeat when you reach 21), and that's not the limit: Arabic has 6 such forms. So to avoid things like "You have 0 message(s)", you need some utility function. `scUtils.pluralize` and `scUtils.pluralizeFmt` provide that. 478 | 479 | `scUtils.pluralize` takes array of cases and amount: `scUtils.pluralize(['cat', 'cats'], numberOfCats)` or `scUtils.pluralize(['яблоко', 'яблок', 'яблока'], numberOfApples)` and return proper string. `scUtils.pluralizeFmt` takes array of cases and template and returns a function which takes number and returns cases and number wrapped in template: 480 | ```js 481 | var bulletAmount = window.scUtils.pluralizeFmt(['патрон', 'патрона', 'патронов'], 'У вас ${amount} ${plural}.'); 482 | bulletAmount(10); // -> "У вас 10 патронов." 483 | bulletAmount(1); // -> "У вас 1 патрон." 484 | bulletAmount(3); // -> "У вас 3 патрона." 485 | ``` 486 | 487 | Both **plurals-en.js** and **plurals-ru.js** expose same two functions, difference is **plurals.js** works only in pretty recent Chrome versions and support both English and Russian; it requires `lang="en"` attribute on `` to detect language. **plurals-ru.js** works pretty much everywhere but only supports Russian (should work with any Slavic language actually). 488 | 489 | **NB: [`plurals.js`](./plurals.js) contains language independent pluralizer, based on built-in [Intl.PluralRules](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Intl/PluralRules). Unfortunately, [browser support is still spotty](https://caniuse.com/intl-pluralrules), so it's not really ready for production.** 490 | 491 | --- 492 | 493 | 494 | 495 | ### `preloadImages.js` 496 | 497 | Source: [./preloadImages.js](./preloadImages.js) 498 | 499 | If your logo or background image is heavy, it may take some time to load for a player, resulting in unpleasant effect. You may wish to load images beforehand; `scUtils.preloadImages` shows load screen during this process. It also returns a `Promise` for a finer control on what happens next. 500 | ```js 501 | scUtils.preloadImages(['img/heavy-background.jsp', 'img/huge-logo.png']).then(() => console.log('Images loaded!')); 502 | ``` 503 | 504 | --- 505 | 506 | 507 | 508 | ### `promiseLock.js` 509 | 510 | Source: [./promiseLock.js](./promiseLock.js) 511 | 512 | If you need to load heave scripts, styles, images or other assets, it may be good idea to show load screen during the process. `window.scUtils.promiseLock` accepts a `Promise` object and shows load screen until the promise resolves successfully. 513 | 514 | --- 515 | 516 | 517 | 518 | ### `qbn.js` 519 | 520 | Source: [./qbn.js](./qbn.js) 521 | 522 | Quest tracker, exposes `window.qbn` and `window.scUtils.qbn` objects. 523 | 524 | Suppose player should visit any 5 rooms out of 7 in building before he can proceed with the story, or examine any 3 evidences out of 5 before character comes to conclusion. If character can visit rooms (and return to where they were before) or examine clues in random order, you'll need 7 (or 5) boolean variables and unmaintainable `if()` condition to allow that. You can put some flags into array, but this requires filtering out non-unique values. `qbn` helps with all that: 525 | ```js 526 | qbn.set('house', 'ground floor'); 527 | qbn.set('house', ['basement', 'kitchen']); 528 | if (qbn.length('house') === 3) { alert('house fully explored') } 529 | qbn.set('house', 'ground floor'); // qbn.length('dungeon') still equals 2 530 | 531 | // these methods more useful when tracking some character qualities (like in Sunless Sea) 532 | qbn.unset('island', 'pleasant acquaintance'); 533 | qbn.inc('madness', 12); // time to eat your crew yet? 534 | qbn.dec('madness', 5); // qbn.length('madness') === 7 535 | ``` 536 | 537 | --- 538 | 539 | 540 | 541 | ### `skipIntro.js` 542 | 543 | Source: [./skipIntro.js](./skipIntro.js) 544 | 545 | Simple utility that inserts "Skip intro" link into certain passages starting from 2nd playthrough. 546 | ```js 547 | scUtils.skipIntro( 548 | 'My first action passage', // passage to jump to 549 | 'Skip boring stuff', // label for the link, defaults to l10nStrings.uiSkipIntro or 'Skip intro' 550 | ['Start'], // passages **names** which shouldn't have this link (e.g. splash screen) 551 | ['no-skip-intro'], // passages **tags** which shouldn't have this link 552 | ) 553 | ``` 554 | Comes with predefined styles, which can be easily overridden: 555 | ```css 556 | p.skipIntro { 557 | text-align: center; 558 | font-size: 300%; 559 | } 560 | ``` 561 | 562 | --- 563 | 564 | 565 | 566 | ### `volumeButtons.js` 567 | 568 | Source: [./volumeButtons.js](./volumeButtons.js) (relies on menuButton.js) 569 | 570 | Exposes `scUtils.createVolumeButtons` function, which adds volume control buttons to the UI bar. To change button label, assign value to correspondent l10n key:`l10nStrings.uiVolumeControl = 'Level of AWESOME'`. 571 | 572 | ```js 573 | const step = 0.2; // volume changes from 0 to 1. 574 | const labels = ['🔈', '🔇', '🔊']; 575 | scUtils.createVolumeButtons(step, volume); 576 | ``` 577 | 578 | --- 579 | 580 | ## Using the code 581 | 582 | Code in the repo uses pretty new JS language features and as-is will work only in fresh Chrome and FF and latest Safari (last 2-3 years). This is fine if you're wrapping your game in NW.js or Electron or during debug stages, but may be unacceptable for web distribution. To remedy that, use `bin/build.js` script like so: 583 | ```sh 584 | node bin/build.js --es6 abbr about faint genderswitch 585 | ``` 586 | 587 | This will create `./bundle.js` combining transpiled `abbr.js`, `about.js` `faint.js` and `genderswitch.js` files. Additionally, you can produce a minified version adding `--compress` option: 588 | ```sh 589 | node bin/build.js --es6 --compress abbr about faint genderswitch 590 | ``` 591 | 592 | By default, code will be transpiled to support same browsers as SugarCube. If you don't have node.js installed, you can transpile code [online](http://babeljs.io/repl/). 593 | 594 | --- 595 | 596 | ## MIT License 597 | Copyright 2017-2020 Konstantin Kitmanov. 598 | 599 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 600 | 601 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 602 | 603 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 604 | 605 | -------------------------------------------------------------------------------- /abbr.js: -------------------------------------------------------------------------------- 1 | (function abbrMacro() { 2 | // usage: <> 3 | 'use strict'; 4 | /* globals version, Macro */ 5 | 6 | if (!version || !version.title || 'SugarCube' !== version.title || !version.major || version.major < 2) { 7 | throw new Error('<> macro requires SugarCube 2.0 or greater, aborting load'); 8 | } 9 | 10 | version.extensions.abbr = {major: 1, minor: 2, revision: 1}; 11 | 12 | const clsPrefix = 'abbr-macro'; 13 | 14 | const styles = ` 15 | .${clsPrefix} { 16 | position: relative; 17 | display: inline; 18 | cursor: pointer; 19 | border-bottom: 1px dotted; 20 | } 21 | .${clsPrefix}::before { 22 | content: attr(data-title); 23 | position: absolute; 24 | display: table; 25 | top: 100%; 26 | left: 0; 27 | z-index: 10; 28 | max-width: 25vw; 29 | padding: 0.3em; 30 | font-size: 90%; 31 | pointer-events: none; 32 | opacity: 0; 33 | border: 1px solid currentColor; 34 | transition: 150ms linear all; 35 | } 36 | .${clsPrefix}:active::before, 37 | .${clsPrefix}:hover::before { 38 | pointer-events: auto; 39 | opacity: 1; 40 | transition: 150ms linear all; 41 | } 42 | /* to avoid setting bg color manually */ 43 | #story, #passages, .passage, .passage *, .passage * .${clsPrefix}, .${clsPrefix}::before { 44 | background-color: inherit; 45 | }`; 46 | 47 | jQuery('head').append(``); 48 | 49 | Macro.add('abbr', { 50 | handler () { 51 | const abr = jQuery(`${this.args[0]}`); 52 | abr.appendTo(this.output); 53 | }, 54 | }); 55 | }()); -------------------------------------------------------------------------------- /about.js: -------------------------------------------------------------------------------- 1 | (function storyAboutBtn() { 2 | 'use strict'; 3 | // requires menuButton.js 4 | // 5 | // Adds "About" button to UI Bar -- great for things like credits 6 | // * doesn't show up unless passage named StoryAbout exists 7 | // * opens dialog with the same title and contents filled from StoryAbout passage 8 | // * change l10nStrings.uiBarAbout to change both button and dialog title 9 | 10 | /* globals Story, scUtils, l10nStrings */ 11 | 12 | if (!Story.has('StoryAbout')) { 13 | return; 14 | } 15 | 16 | scUtils.createPassageButton(l10nStrings.uiBarAbout || 'About', '\\e809\\00a0', 'StoryAbout'); 17 | }()); 18 | -------------------------------------------------------------------------------- /achievements/AchievementManager.js: -------------------------------------------------------------------------------- 1 | (function achievementManagerFactory() { 2 | 'use strict'; 3 | 4 | class AchievementManager { 5 | /** 6 | * @param {IAchievement[]} list 7 | * @param {Function} onUnlock 8 | */ 9 | constructor(list, onUnlock) { 10 | this.list = list; 11 | this.onUnlock = onUnlock; 12 | 13 | const weight = this.list.reduce((weight, /** @type {IAchievement} */achievement) => { 14 | return weight + achievement.weight; 15 | }, 0); 16 | 17 | if (!isNaN(weight)) { 18 | this.totalWeight = weight; 19 | } 20 | } 21 | 22 | test() { 23 | /** @type IAchievement[] */ 24 | const unlocked = []; // it's possible to unlock several achievements at once 25 | for (const achievement of this.list) { 26 | if (!achievement.unlocked && achievement.test()) { 27 | achievement.unlocked = true; 28 | achievement.date = new Date(); 29 | unlocked.push(achievement); 30 | } 31 | } 32 | 33 | if (unlocked.length) { 34 | this.onUnlock(unlocked); 35 | } 36 | } 37 | 38 | /** 39 | * @return {IAchievementOverview} 40 | */ 41 | getOverview() { 42 | /** @type IAchievementOverview */ 43 | const overview = { 44 | locked: [], 45 | unlocked: [], 46 | hidden: 0, 47 | weight: 0, 48 | }; 49 | 50 | return this.list.reduce((overview, achievement) => { 51 | if (achievement.unlocked) { 52 | overview.unlocked.push(achievement); 53 | if (this.totalWeight) { 54 | overview.weight += achievement.weight; 55 | } 56 | } else { 57 | if (achievement.hidden) { 58 | overview.hidden += 1; 59 | } else { 60 | overview.locked.push(achievement); 61 | } 62 | } 63 | 64 | return overview; 65 | }, overview); 66 | } 67 | } 68 | 69 | window.scUtils = Object.assign(window.scUtils || {}, { 70 | AchievementManager, 71 | }); 72 | }()); 73 | -------------------------------------------------------------------------------- /achievements/AchievementRenderer.js: -------------------------------------------------------------------------------- 1 | (function achievementRendererFactory(dateFormat, dialogTitle, pluralize) { 2 | 'use strict'; 3 | 4 | /* globals scUtils, Dialog, Story */ 5 | 6 | function defaultDateFormat (date) { 7 | return date.toString(); 8 | } 9 | 10 | dateFormat = dateFormat === null 11 | ? null 12 | : (dateFormat || defaultDateFormat); 13 | 14 | class AchievementRenderer { 15 | /** 16 | * @param {IAchievement[]} achievements 17 | */ 18 | constructor(achievements) { 19 | this.storage = new window.scUtils.AchievementStorage(); 20 | 21 | jQuery(document).on(':passagedisplay', this.onPassageDisplay.bind(this)); 22 | 23 | this.$notificationContainer = this.createNotificationContainer(); 24 | 25 | this.setupSidebarButton(); 26 | 27 | this.setupIcon(); 28 | 29 | this.load().then((unlocked) => { 30 | this.prepareUnlockedAchievements(achievements, unlocked); 31 | 32 | this.manager = new window.scUtils.AchievementManager(achievements, this.onUnlock.bind(this)); 33 | }); 34 | } 35 | 36 | /** 37 | * @param {IAchievement[]} achievements 38 | * @param {IStoredAchievement[]} unlocked 39 | */ 40 | prepareUnlockedAchievements(achievements, unlocked) { 41 | achievements.forEach((achievement) => { 42 | const unlockedItem = unlocked.find((a) => achievement.id === a.id); 43 | if (unlockedItem) { 44 | achievement.unlocked = true; 45 | achievement.date = unlockedItem.date; 46 | } 47 | }); 48 | } 49 | 50 | /** 51 | * @return {jQuery} 52 | */ 53 | createNotificationContainer() { 54 | const $notificationContainer = jQuery('
'); 55 | $notificationContainer.appendTo('body'); 56 | $notificationContainer.on('click', () => { 57 | this.displayAchievementsList(); 58 | }); 59 | 60 | return $notificationContainer; 61 | } 62 | 63 | setupSidebarButton() { 64 | scUtils.createHandlerButton(dialogTitle, '\\e809\\00a0', 'achievements', () => { 65 | this.displayAchievementsList(); 66 | }); 67 | } 68 | 69 | setupIcon() { 70 | this._icon = Story.get('icon-achievement').processText(); 71 | } 72 | 73 | /** 74 | * @return {Promise} 75 | */ 76 | async load() { 77 | return await this.storage.load(); 78 | } 79 | 80 | /** 81 | * @param {IStoredAchievement[]} items 82 | * @return {Promise} 83 | */ 84 | async save(items) { 85 | await this.storage.save(items); 86 | } 87 | 88 | /** 89 | * @param {IAchievement[]} achievements 90 | * @return {Promise} 91 | */ 92 | async onUnlock(achievements) { 93 | const existingItems = await this.load(); 94 | const toSave = achievements.map((a) => { 95 | return { id: a.id, date: a.date.toString() }; 96 | }); 97 | await this.save([...existingItems, ...toSave]); 98 | this.displayNotification(achievements); 99 | } 100 | 101 | /** 102 | * @param {IAchievement[]} achievements 103 | */ 104 | displayNotification(achievements) { 105 | this.$notificationContainer.html( 106 | achievements.map(this.renderAchievement, this) 107 | ); 108 | this.$notificationContainer.addClass('open'); 109 | 110 | setTimeout(this.hideNotification.bind(this), 5000); 111 | } 112 | 113 | hideNotification() { 114 | this.$notificationContainer.removeClass('open'); 115 | this.$notificationContainer.one('animationend', () => { 116 | this.$notificationContainer.html(''); 117 | }); 118 | } 119 | 120 | displayAchievementsList() { 121 | const overview = this.manager.getOverview(); 122 | 123 | let html = ` 124 | ${overview.unlocked.map(this.renderAchievement, this).join('')} 125 | `; 126 | 127 | if (overview.hidden > 0) { 128 | const hiddenAchievements = pluralize(overview.hidden, overview.unlocked.length); 129 | html += `

${hiddenAchievements}

`; 130 | } 131 | 132 | Dialog.setup(dialogTitle, 'achievement-dialog'); 133 | Dialog.append(html); 134 | Dialog.open(); 135 | } 136 | 137 | onPassageDisplay() { 138 | this.manager.test(); 139 | } 140 | 141 | /** 142 | * @param {IAchievement} achievement 143 | * @return {string} 144 | */ 145 | renderAchievement(achievement) { 146 | const dateLine = dateFormat ? `

${dateFormat(achievement.date)}

` : ''; 147 | return ` 148 |
149 | ${this._icon} 150 |
151 |
${achievement.title}
152 |

${achievement.description}

153 | ${dateLine} 154 |
155 |
156 | `; 157 | } 158 | } 159 | 160 | window.scUtils = Object.assign(window.scUtils || {}, { 161 | AchievementRenderer, 162 | }); 163 | }( 164 | null, /* null disables showing date completely; pass a function here to format date, or undefined for default formatting */ 165 | 'Achievements', /* Dialog and button title */ 166 | (hiddenAchievementsCount, unlockedAchievementsCount) => { /* Pluralizer function (which renders 'And 3 hidden achievements' after the list) */ 167 | const template = unlockedAchievementsCount > 0 ? 'And ${amount} ${plural}.' : '${amount} ${plural}.'; 168 | return scUtils.pluralizeFmt(['hidden achievement', 'hidden achievements'], template)(hiddenAchievementsCount); 169 | } 170 | )); -------------------------------------------------------------------------------- /achievements/AchievementStorage.js: -------------------------------------------------------------------------------- 1 | (function achievementStorageFactory() { 2 | 'use strict'; 3 | 4 | /* globals storage */ 5 | 6 | class AchievementStorage { 7 | /** 8 | * @param {IStoredAchievement[]} items 9 | */ 10 | async save(items) { 11 | await storage.set( 12 | 'unlocked', 13 | JSON.stringify(items) 14 | ); 15 | } 16 | 17 | /** 18 | * @return {IStoredAchievement[]} 19 | */ 20 | async load() { 21 | return await JSON.parse(storage.get('unlocked') || '[]').map((a) => { 22 | return {id: a.id, date: new Date(a.date)}; 23 | }); 24 | } 25 | } 26 | 27 | window.scUtils = Object.assign(window.scUtils || {}, { 28 | AchievementStorage, 29 | }); 30 | }()); -------------------------------------------------------------------------------- /achievements/achievement.css: -------------------------------------------------------------------------------- 1 | .achievements-container { 2 | position: fixed; 3 | bottom: 1em; 4 | right: 1em; 5 | 6 | opacity: 0; 7 | pointer-events: none; 8 | transition: 150ms all ease-in; 9 | cursor: pointer; 10 | } 11 | .achievements-container.open { 12 | opacity: 1; 13 | pointer-events: auto; 14 | transition: all 50ms ease-out; 15 | } 16 | 17 | .achievements-container { 18 | position: fixed; 19 | bottom: 1em; 20 | right: 1em; 21 | 22 | opacity: 0; 23 | pointer-events: none; 24 | transition: 150ms all ease-in; 25 | cursor: pointer; 26 | } 27 | .achievements-container.open { 28 | opacity: 1; 29 | pointer-events: auto; 30 | transition: all 50ms ease-out; 31 | } 32 | 33 | .achievement { 34 | width: 20em; 35 | height: 5em; 36 | padding: 0.5em; 37 | border: 1px solid currentColor; 38 | background-color: inherit !important; 39 | box-shadow: 0em 0em 1em 1em; 40 | 41 | display: flex; 42 | justify-content: space-between; 43 | align-items: stretch; 44 | flex-direction: row; 45 | } 46 | .achievement-dialog .achievement { 47 | box-shadow: none; 48 | display: inline-flex; 49 | margin-right: 0.5em; 50 | margin-bottom: 0.5em; 51 | } 52 | 53 | .achievement-icon { 54 | margin-right: 0.5em; 55 | width: 6em; 56 | fill: currentColor; 57 | } 58 | 59 | .achievement-content { 60 | display: flex; 61 | justify-content: space-around; 62 | flex-direction: column; 63 | height: 100%; 64 | flex-grow: 1; 65 | } 66 | 67 | .achievement-title { 68 | font-size: 130%; 69 | margin: 0; 70 | line-height: 1; 71 | } 72 | .achievement-text { 73 | font-size: 90%; 74 | margin: 0; 75 | line-height: 1; 76 | } 77 | .achievement-date { 78 | font-size: 80%; 79 | margin: 0; 80 | line-height: 1; 81 | } 82 | 83 | .achievement-dialog { 84 | width: 44em; 85 | } -------------------------------------------------------------------------------- /achievements/story.twee: -------------------------------------------------------------------------------- 1 | ::StoryTitle 2 | twine-achievements 3 | 4 | ::StoryAbout 5 | This is Twee 3 code. Learn more here: [[https://twinery.org/cookbook/terms/terms_twee.html]]. 6 | 7 | Icon used ("icon-achievement" passage): [[https://game-icons.net/1x1/skoll/achievement.html]]. You ''must'' properly credit the original, or use your own image. 8 | 9 | Features: 10 | * displays notification in lower-right corner when achievement is awarded 11 | * adds a button to sidebar to view player's achievements progress 12 | * remembers achievements between playthroughs and page reloads 13 | * achievements include title, description and (optionally) date when it was awarded 14 | * achievements can be "hidden" to avoid spoilers and hints 15 | * achievements can have "weight", so, for example, "golden" trophy contributes 10% of overall progress, "silver" just 5% and so on. 16 | 17 | Conditions for achievements are tested when player transits to a passage, but you can call it manually any time: 18 | {{{"""<>"""}}} 19 | 20 | Prerequisites: 21 | * You should include {{{menuButton.js}}}: [[https://github.com/hogart/sugar-cube-utils#menubuttonjs]] 22 | * You should include pluralizer of some sort (or write your own): [[https://github.com/hogart/sugar-cube-utils#plurals-enjs-and-plurals-rujs]] 23 | 24 | 25 | ::icon-achievement 26 | 27 | 28 | 29 | 30 | 31 | ::StoryInit 32 | <> -------------------------------------------------------------------------------- /achievements/types.ts: -------------------------------------------------------------------------------- 1 | export interface IAchievement { 2 | id: string; 3 | title: string; 4 | description: string; 5 | check: () => boolean; 6 | unlocked: boolean; 7 | hidden: boolean; 8 | weight: number | null; 9 | date: Date | null; 10 | } 11 | 12 | export interface IAchievementOverview { 13 | locked: IAchievement[]; 14 | unlocked: IAchievement[]; 15 | hidden: number; 16 | weight: number; 17 | } 18 | 19 | export interface IStoredAchievement { 20 | id: string; 21 | date: Date | null; 22 | } -------------------------------------------------------------------------------- /bin/build.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | /* eslint-env node */ 3 | const fs = require('fs'); 4 | const path = require('path'); 5 | const babel = require('babel-core'); 6 | 7 | const files = process.argv.slice(2); 8 | 9 | let compress = false; 10 | let es6 = false; 11 | 12 | function readFiles(options) { 13 | return options.map((option) => { 14 | if (option === '--compress') { 15 | compress = true; 16 | return null; 17 | } 18 | 19 | if (option === '--es6') { 20 | es6 = true; 21 | return null; 22 | } 23 | 24 | let fileContent = ''; 25 | try { 26 | fileContent = fs.readFileSync(path.resolve('.', option + '.js'), {}).toString(); 27 | } catch(e) { 28 | console.error(e); 29 | fileContent = `/* Error reading file ${option} */`; 30 | } 31 | 32 | return fileContent; 33 | }).filter((fileContent) => fileContent !== null); 34 | } 35 | 36 | function concat(files) { 37 | let bundle = files.join('\n\n'); 38 | 39 | if (compress || es6) { 40 | const presets = []; 41 | if (es6) { 42 | presets.push('env'); 43 | } 44 | if (compress) { 45 | presets.push('minify'); 46 | } 47 | 48 | bundle = babel.transform(bundle, { 49 | presets, 50 | }); 51 | } 52 | 53 | fs.writeFileSync(path.resolve('./bundle.js'), bundle.code || bundle, {}); 54 | } 55 | 56 | concat( 57 | readFiles( 58 | files 59 | ) 60 | ); -------------------------------------------------------------------------------- /daymode.css: -------------------------------------------------------------------------------- 1 | /* 2 | DAYMODE - A bleached theme adaptation for on-the-fly switching 3 | Add following JS (or something like this): 4 | 5 | (function () { 6 | 'use strict'; 7 | 8 | const $html = $('html'); 9 | const template = ``; 10 | 11 | let isOn; 12 | 13 | if (storage.has('dayMode')) { 14 | isOn = storage.get('dayMode'); 15 | } else if ('dayMode' in State.variables) { 16 | isOn = State.variables.dayMode; 17 | } 18 | 19 | const $button = jQuery(template) 20 | .ariaClick(() => { 21 | $html.toggleClass('daymode'); 22 | isOn = !isOn; 23 | storage.set('dayMode', isOn); 24 | }); 25 | 26 | $button.appendTo('#menu-core'); 27 | 28 | if (isOn) { 29 | $html.addClass('daymode'); 30 | } 31 | }()); 32 | */ 33 | .daymode body { 34 | color: #111; 35 | background-color: #fff; 36 | } 37 | .daymode a { 38 | color: #35c; 39 | } 40 | .daymode a:hover { 41 | color: #57e; 42 | } 43 | .daymode span.link-disabled { 44 | color: #777; 45 | } 46 | .daymode button { 47 | color: #111; 48 | background-color: #acf; 49 | border-color: #8ad; 50 | } 51 | .daymode button:hover { 52 | background-color: #8ad; 53 | border-color: #68b; 54 | } 55 | .daymode button:disabled { 56 | background-color: #ccc; 57 | border-color: #aaa; 58 | } 59 | .daymode input, 60 | .daymode select, 61 | .daymode textarea { 62 | color: #111; 63 | border-color: #ccc; 64 | } 65 | .daymode input:focus, 66 | .daymode select:focus, 67 | .daymode textarea:focus, 68 | .daymode input:hover, 69 | .daymode select:hover, 70 | .daymode textarea:hover { 71 | background-color: #eee; 72 | border-color: #111; 73 | } 74 | .daymode hr { 75 | border-color: #111; 76 | } 77 | 78 | .daymode .error { 79 | background-color: #eaa; 80 | border-left-color: #d77; 81 | } 82 | 83 | .daymode #ui-bar { 84 | background-color: #eee; 85 | border-color: #ccc; 86 | } 87 | .daymode #ui-bar hr { 88 | border-color: #ccc; 89 | } 90 | .daymode #ui-bar-toggle, 91 | .daymode #ui-bar-history [id|="history"] { 92 | color: #111; 93 | border-color: #ccc; 94 | } 95 | .daymode #ui-bar-toggle:hover, 96 | .daymode #ui-bar-history [id|="history"]:hover { 97 | background-color: #ccc; 98 | border-color: #111; 99 | } 100 | .daymode #ui-bar-history [id|="history"]:disabled { 101 | color: #ccc; 102 | background-color: transparent; 103 | border-color: #ccc; 104 | } 105 | .daymode #menu ul { 106 | border-color: #ccc; 107 | } 108 | .daymode #menu li:not(:first-child) { 109 | border-top-color: #ccc; 110 | } 111 | .daymode #menu li a { 112 | color: #111; 113 | } 114 | .daymode #menu li a:hover { 115 | background-color: #ccc; 116 | border-color: #111; 117 | } 118 | 119 | /* Default dialog styling */ 120 | .daymode #ui-overlay { 121 | background-color: #777; 122 | } 123 | .daymode #ui-dialog-titlebar { 124 | background-color: #ccc; 125 | } 126 | .daymode #ui-dialog-close:hover { 127 | background-color: #b44; 128 | border-color: #a33; 129 | } 130 | .daymode #ui-dialog-body { 131 | background-color: #fff; 132 | border-color: #ccc; 133 | } 134 | .daymode #ui-dialog-body hr { 135 | background-color: #ccc; 136 | } 137 | 138 | /* List-based dialog styling */ 139 | .daymode #ui-dialog-body.list li:not(:first-child) { 140 | border-top-color: #ccc; 141 | } 142 | .daymode #ui-dialog-body.list li a { 143 | color: #111; 144 | } 145 | .daymode #ui-dialog-body.list li a:hover { 146 | background-color: #ccc; 147 | border-color: #111; 148 | } 149 | 150 | /* Saves dialog styling */ 151 | .daymode #ui-dialog-body.saves > *:not(:first-child), 152 | .daymode #ui-dialog-body.saves tr:not(:first-child) { 153 | border-top-color: #ccc; 154 | } 155 | .daymode #ui-dialog-body.saves .empty { 156 | color: #777; 157 | } 158 | 159 | /* Settings dialog styling */ 160 | .daymode #ui-dialog-body.settings button[id|="setting-control"] { 161 | color: #111; 162 | border-color: #ccc; 163 | } 164 | .daymode #ui-dialog-body.settings button[id|="setting-control"]:hover { 165 | background-color: #eee; 166 | border-color: #111; 167 | } 168 | .daymode #ui-dialog-body.settings button[id|="setting-control"].enabled { 169 | background-color: #9e9; 170 | border-color: #7c7; 171 | } 172 | .daymode #ui-dialog-body.settings button[id|="setting-control"].enabled:hover { 173 | background-color: #7c7; 174 | border-color: #5a5; 175 | } 176 | 177 | /* Debug view styling */ 178 | html.daymode:not([data-debug-view]) #debug-view-toggle { 179 | color: #111; 180 | border-color: #ccc; 181 | } 182 | html.daymode:not([data-debug-view]) #debug-view-toggle:hover { 183 | background-color: #eee; 184 | border-color: #111; 185 | } 186 | html.daymode[data-debug-view] #debug-view-toggle { 187 | background-color: #9e9; 188 | border-color: #7c7; 189 | } 190 | html.daymode[data-debug-view] #debug-view-toggle:hover { 191 | background-color: #7c7; 192 | border-color: #5a5; 193 | } 194 | html.daymode[data-debug-view] .debug { 195 | background-color: #dc9; 196 | } 197 | html.daymode[data-debug-view] .debug.hidden, 198 | html.daymode[data-debug-view] .debug.hidden .debug { 199 | background-color: #bbb; 200 | } 201 | 202 | /* Style menu item to have "moon" icon */ 203 | #menu-core #menu-item-skin a::before { 204 | content: '\e800\00a0'; 205 | } 206 | .daymode #menu-core #menu-item-skin a::before { 207 | content: '\e801\00a0'; 208 | } -------------------------------------------------------------------------------- /daymode.js: -------------------------------------------------------------------------------- 1 | (function daymodeBtn() { 2 | // see daymode.css 3 | // 4 | // requires menuButton.js 5 | // 6 | // Adds 'day/night mode toggle button to menu dock' 7 | 8 | 'use strict'; 9 | 10 | /* globals storage, State, l10nStrings, scUtils */ 11 | 12 | const $html = jQuery('html'); 13 | 14 | let isOn; 15 | 16 | if (storage.has('dayMode')) { 17 | isOn = storage.get('dayMode'); 18 | } else if ('dayMode' in State.variables) { 19 | isOn = State.variables.dayMode; 20 | } 21 | 22 | function handler() { 23 | $html.toggleClass('daymode'); 24 | isOn = !isOn; 25 | storage.set('dayMode', isOn); 26 | } 27 | 28 | if (isOn) { 29 | $html.addClass('daymode'); 30 | } 31 | 32 | scUtils.createHandlerButton(l10nStrings.uiBarNightMode || 'Day mode', '', 'skin', handler); 33 | }()); -------------------------------------------------------------------------------- /detectLang.js: -------------------------------------------------------------------------------- 1 | (function detectLangFactory() { 2 | 'use strict'; 3 | 4 | const pathNameMatcher = /(-[a-z]{2})?\.html$/; 5 | function byPath() { 6 | let lang = 'ru'; 7 | const match = window.location.pathname.match(pathNameMatcher); 8 | if (match && match[1]) { 9 | lang = match[1].substr(1); 10 | } 11 | 12 | return lang; 13 | } 14 | 15 | window.scUtils = Object.assign( 16 | window.scUtils || {}, 17 | { 18 | detectLang: { 19 | byPath, 20 | }, 21 | } 22 | ); 23 | }()); -------------------------------------------------------------------------------- /dlg.js: -------------------------------------------------------------------------------- 1 | (function dlgMacros(macroOptions) { 2 | 'use strict'; 3 | /* globals version, Macro */ 4 | 5 | if (!version || !version.title || 'SugarCube' !== version.title || !version.major || version.major < 2) { 6 | throw new Error('<> macro requires SugarCube 2.0 or greater, aborting load'); 7 | } 8 | 9 | function getOrCreate(id) { 10 | let element = jQuery('#' + id); 11 | if (element.length === 0) { 12 | element = jQuery(`
`); 13 | } 14 | 15 | return element; 16 | } 17 | 18 | function isNumber(arg) { 19 | return typeof arg === 'number'; 20 | } 21 | 22 | function getLevels(ctx) { 23 | let level = 0; 24 | const parentLevel = ctx.contextSelect((context) => context.name === 'level'); 25 | if (parentLevel) { 26 | level = parseInt(parentLevel.args[0], 10); 27 | } 28 | 29 | let targetLevel = level + 1; 30 | if (isNumber(ctx.args[1])) { 31 | targetLevel = parseInt(ctx.args[1], 10); 32 | } else if (isNumber(ctx.args[2])) { 33 | targetLevel = parseInt(ctx.args[2], 10); 34 | } 35 | 36 | return {level, targetLevel}; 37 | } 38 | 39 | Macro.add('line', { 40 | tags: null, 41 | handler() { 42 | const {level, targetLevel} = getLevels(this); 43 | 44 | let dlgId = 'dlg'; 45 | const parentDlg = this.contextSelect((context) => context.name === 'dlg'); 46 | const dlgArgs = parentDlg.args; 47 | 48 | if (parentDlg && dlgArgs[0]) { 49 | dlgId = dlgArgs[0]; 50 | } 51 | 52 | let bullet = ''; 53 | if (this.args.length >= 2 && !isNumber(this.args[1])) { 54 | bullet = this.args[1]; 55 | } else if (parentDlg && parentDlg.args.length === 3) { 56 | bullet = parentDlg.args[2]; 57 | } 58 | 59 | let line; 60 | if (this.args.length === 0) { 61 | line = this.payload[0].content; 62 | } else { 63 | line = this.args[0]; 64 | } 65 | 66 | const doTrim = !!macroOptions.trim; 67 | const doPrepend = !!macroOptions.prepend; 68 | 69 | const link = jQuery(''); 70 | link.wiki(`${bullet ? ('' + bullet + ' ') : ''}${line}`); 71 | link.ariaClick(() => { 72 | const response = doTrim ? this.payload[0].contents.trim() : this.payload[0].contents; 73 | const result = doPrepend ? (`${line}\n${response}`) : response; 74 | jQuery('#' + dlgId).wiki((level > 0 ? '\n' : '') + result); 75 | 76 | const dlgStage = jQuery('#' + dlgId + '-stage'); 77 | dlgStage.find('.dlg-level-' + level).attr('hidden', 'hidden'); 78 | dlgStage.find('.dlg-level-' + targetLevel).removeAttr('hidden'); 79 | }); 80 | 81 | jQuery(this.output).append(link); 82 | }, 83 | }); 84 | 85 | Macro.add('level', { 86 | tags: [], 87 | handler() { 88 | const level = parseInt(this.args[0], 10); 89 | if (isNaN(level)) { 90 | this.error('missing <> <> number'); 91 | } else { 92 | const wrapper = jQuery(``); 93 | for (const payload of this.payload) { 94 | wrapper.wiki(payload.contents); 95 | } 96 | 97 | jQuery(this.output).append(wrapper); 98 | } 99 | }, 100 | }); 101 | 102 | Macro.add('dlg', { 103 | tags: [], 104 | handler() { 105 | const dlgId = this.args[0] || 'dlg'; 106 | 107 | const currentLevel = this.args.length === 2 ? parseInt(this.args[1], 10) : 0; 108 | 109 | let dlgWrapper = getOrCreate(dlgId); 110 | let dlgStage = getOrCreate(dlgId + '-stage'); 111 | 112 | for (const payload of this.payload) { 113 | dlgStage.wiki(payload.contents); 114 | } 115 | 116 | dlgStage.find('.dlg-level-' + currentLevel).removeAttr('hidden', 'hidden'); 117 | 118 | jQuery(this.output) 119 | .append(dlgWrapper) 120 | .append(dlgStage); 121 | }, 122 | }); 123 | }({trim: false, prepend: false})); -------------------------------------------------------------------------------- /faint.js: -------------------------------------------------------------------------------- 1 | (function faintFactory() { 2 | 'use strict'; 3 | 4 | const clsPrefix = 'faint'; 5 | 6 | const styles = ` 7 | .${clsPrefix} { 8 | z-index: 1000; 9 | position: fixed; 10 | top: 0; 11 | left: 0; 12 | height: 100%; 13 | width: 100%; 14 | 15 | visibility: hidden; 16 | opacity: 0; 17 | transition: 1s linear; 18 | transition-property: opacity, visibility; 19 | } 20 | .${clsPrefix}.open { 21 | visibility: visible; 22 | opacity: 1; 23 | transition: 1s linear; 24 | transition-property: opacity, visibility; 25 | } 26 | 27 | html.${clsPrefix}-blur { 28 | filter: blur(10px); 29 | transition: 1s filter linear; 30 | /* without these 2 lines, firefox does weird things */ 31 | height: 100%; 32 | overflow: hidden; 33 | } 34 | `; 35 | 36 | jQuery(``).appendTo('head'); 37 | 38 | const body = jQuery('body'); 39 | const doc = jQuery('html'); 40 | const overlay = jQuery(`
`); 41 | overlay.appendTo(body); 42 | 43 | function comeTo() { 44 | overlay.removeClass('open'); 45 | doc.removeClass(`${clsPrefix}-blur`); 46 | } 47 | 48 | function faint(callback = null, duration = 5, color = 'black', blur = true) { 49 | overlay.css({backgroundColor: color}); 50 | 51 | if (blur) { 52 | doc.addClass(`${clsPrefix}-blur`); 53 | } 54 | 55 | overlay.addClass('open'); 56 | 57 | setTimeout(() => { 58 | if (callback) { 59 | callback(); 60 | setTimeout(comeTo, 100); // make a small delay to let callback finish 61 | } else { 62 | comeTo(); 63 | } 64 | 65 | }, duration * 1000); 66 | } 67 | 68 | window.scUtils = Object.assign( 69 | window.scUtils || {}, 70 | { 71 | faint, 72 | } 73 | ); 74 | }()); -------------------------------------------------------------------------------- /fontSize.js: -------------------------------------------------------------------------------- 1 | (function fontSizeBtn() { 2 | 'use strict'; 3 | 4 | // requires menuButton.js 5 | 6 | /* globals scUtils, l10nStrings, storage */ 7 | 8 | const $passages = document.querySelector('#passages'); 9 | 10 | function saveFontSize(value) { 11 | storage.set('fontSize', value); 12 | } 13 | 14 | function loadFontSize() { 15 | const loaded = storage.get('fontSize') || '100'; 16 | let value = parseInt(loaded); 17 | 18 | if (isNaN(value)) { 19 | return 100; 20 | } else { 21 | return value; 22 | } 23 | } 24 | 25 | function applyFontSize(value) { 26 | $passages.style.fontSize = `${value}%`; 27 | } 28 | 29 | function createFontSizeBtn(interval = 10, min = 60, max = 200) { 30 | let fs = loadFontSize(); 31 | 32 | if (fs !== 100) { 33 | applyFontSize(fs); 34 | saveFontSize(fs); 35 | } 36 | 37 | const ops = { 38 | inc() { 39 | fs += interval; 40 | fs = Math.min(fs, max); 41 | }, 42 | dec() { 43 | fs -= interval; 44 | fs = Math.max(fs, min); 45 | }, 46 | }; 47 | 48 | function updateUI(button) { 49 | button.find('a').removeAttr('disabled'); 50 | 51 | if (fs === min) { 52 | button.find('a:eq(0)').attr('disabled', true); 53 | } else if (fs === max) { 54 | button.find('a:eq(1)').attr('disabled', true); 55 | } 56 | } 57 | 58 | const {button} = scUtils.createMultiButton('fontSize', l10nStrings.uiFontSize || 'Font size', ['-', '+'], (event, index) => { 59 | ops[index === 0 ? 'dec' : 'inc'](); 60 | 61 | updateUI(button); 62 | 63 | applyFontSize(fs); 64 | saveFontSize(fs); 65 | }); 66 | 67 | updateUI(button); 68 | } 69 | 70 | window.scUtils = Object.assign( 71 | window.scUtils || {}, 72 | { 73 | createFontSizeBtn, 74 | } 75 | ); 76 | }()); -------------------------------------------------------------------------------- /fullscreen.js: -------------------------------------------------------------------------------- 1 | (function fullscreenBtn() { 2 | 'use strict'; 3 | 4 | // requires menuButton.js 5 | // 6 | // fullscreen API is a pain at the moment 7 | // https://developer.mozilla.org/en-US/docs/Web/API/Fullscreen_API 8 | 9 | /* globals l10nStrings, scUtils */ 10 | 11 | // each vendor has it's spelling, so we can't just iterate prefixes 12 | const isFullScreenEnabled = document.fullscreenEnabled || document.webkitFullscreenEnabled || document.mozFullScreenEnabled || document.msFullscreenEnabled; 13 | if (!isFullScreenEnabled) { 14 | // no fullscreen, don't create the button 15 | return; 16 | } 17 | 18 | function isFullScreen(doc) { 19 | return !!(doc.fullscreenElement || doc.webkitFullscreenElement || doc.mozFullScreenElement || doc.msFullscreenElement); 20 | } 21 | 22 | function getRequestFullScreenFn(el) { 23 | return (el.requestFullscreen || el.webkitRequestFullscreen || el.mozRequestFullScreen || el.msRequestFullscreen); 24 | } 25 | 26 | function getExitFullScreenFn(doc) { 27 | return doc.exitFullscreen ||doc.webkitExitFullscreen || doc.mozCancelFullScreen || doc.msExitFullscreen; 28 | } 29 | 30 | const requestFullScreenFn = getRequestFullScreenFn(document.documentElement); 31 | // without bound context, there's `Illegal invocation` exception 32 | const requestFullScreen = requestFullScreenFn.bind(document.documentElement); 33 | const exitFullScreen = getExitFullScreenFn(document).bind(document); 34 | 35 | 36 | // stored vendor-prefixes, mapped to requestFullScreen function name 37 | const vendorSelectorsMap = { 38 | requestFullscreen: 'fullscreen', 39 | webkitRequestFullscreen: '-webkit-full-screen', 40 | mozRequestFullScreen: '-moz-full-screen', 41 | msRequestFullscreen: '-ms-full-screen', 42 | }; 43 | 44 | const selector = vendorSelectorsMap[requestFullScreenFn.name]; 45 | 46 | function handler() { 47 | if (isFullScreen(document)) { 48 | exitFullScreen(); 49 | } else { 50 | requestFullScreen(); 51 | } 52 | } 53 | 54 | const {style} = scUtils.createHandlerButton(l10nStrings.uiFullScreen || 'Full screen', '', 'fullscreen', handler); 55 | const styleId = style.attr('id').replace(/-style$/, ''); 56 | const styles = ` 57 | #menu-core #${styleId} a::before { 58 | content: '\\e830\\00a0'; 59 | } 60 | 61 | html:${selector} { 62 | height: 100%; 63 | } 64 | 65 | :${selector} body { 66 | height: calc(100% - 2.5em); 67 | padding-top: 2.5em; 68 | } 69 | 70 | :${selector} #story { 71 | margin-top: 0; 72 | } 73 | 74 | :${selector} #menu-core #${styleId} a::before { 75 | content: '\\e831\\00a0'; 76 | } 77 | `; 78 | 79 | style.text(styles); 80 | }()); -------------------------------------------------------------------------------- /genderswitch.js: -------------------------------------------------------------------------------- 1 | (function genderSwitchMacros() { 2 | // usage: 3 | // My name is <> Watson. 4 | // ... 5 | // Your father says: My dear <>! 6 | 'use strict'; 7 | /* globals version, Macro, State */ 8 | 9 | if (!version || !version.title || 'SugarCube' !== version.title || !version.major || version.major < 2) { 10 | throw new Error('<> macros family requires SugarCube 2.0 or greater, aborting load'); 11 | } 12 | 13 | version.extensions.genderswitch = {major: 1, minor: 0, revision: 0}; 14 | 15 | const clsPrefix = 'gender'; 16 | 17 | const styles = ` 18 | html.${clsPrefix}-f .${clsPrefix}-m, 19 | html.${clsPrefix}-m .${clsPrefix}-f { 20 | display: none; 21 | } 22 | .${clsPrefix}switch-macro { 23 | border-bottom: 1px dotted; 24 | text-decoration: none; 25 | } 26 | .${clsPrefix}switch-macro:hover, .${clsPrefix}switch-macro:active { 27 | border-bottom: 1px solid; 28 | text-decoration: none; 29 | }`; 30 | 31 | jQuery('head').append(``); 32 | const html = document.documentElement; 33 | html.classList.add(`${clsPrefix}-f`); 34 | 35 | function getLayout(female, male) { 36 | return `${female}${male}`; 37 | } 38 | 39 | Macro.add('genderswitch', { 40 | handler () { 41 | if (this.args.full.length === 0) { 42 | return this.error('no variable and values specified'); 43 | } 44 | 45 | const varName = this.args.full.split(' ')[0].replace(/^State\.variables\./, ''); 46 | const layout = getLayout(this.args[1], this.args[2]); 47 | const link = jQuery(`${layout}`); 48 | 49 | link.appendTo(this.output); 50 | 51 | link.ariaClick(() => { 52 | html.classList.toggle(`${clsPrefix}-f`); 53 | html.classList.toggle(`${clsPrefix}-m`); 54 | State.variables[varName] = html.classList.contains(`${clsPrefix}-f`); 55 | }); 56 | }, 57 | }); 58 | 59 | Macro.add('gender', { 60 | handler () { 61 | const layout = getLayout(this.args[0], this.args[1]); 62 | const wrapper = jQuery(`${layout}`); 63 | wrapper.appendTo(this.output); 64 | }, 65 | }); 66 | }()); -------------------------------------------------------------------------------- /hubnav.js: -------------------------------------------------------------------------------- 1 | (function hubnavMacro(renderLinkWrapper) { 2 | 'use strict'; 3 | /* globals version, Story, Config, Macro, passage, Wikifier, State */ 4 | const macroName = 'hubnav'; 5 | 6 | if (!version || !version.title || 'SugarCube' !== version.title || !version.major || version.major < 2) { 7 | throw new Error(`<<${macroName}>> macros family requires SugarCube 2.0 or greater, aborting load`); 8 | } 9 | 10 | renderLinkWrapper = renderLinkWrapper || function(link) { 11 | const span = link.wrap(''); 12 | span.append('
'); 13 | return span; 14 | }; 15 | 16 | function has(obj, prop) { 17 | return Object.prototype.hasOwnProperty.call(obj, prop); 18 | } 19 | 20 | function parseLinkArg(arg) { 21 | let passage; 22 | let $content; 23 | if (arg.isImage) { 24 | // Argument was in wiki image syntax. 25 | $content = jQuery(document.createElement('img')) 26 | .attr('src', arg.source); 27 | 28 | if (has(arg, 'passage')) { 29 | $content.attr('data-passage', arg.passage); 30 | } 31 | 32 | if (has(arg,'title')) { 33 | $content.attr('title', arg.title); 34 | } 35 | 36 | if (has(arg, 'align')) { 37 | $content.attr('align', arg.align); 38 | } 39 | 40 | passage = arg.link; 41 | } else { 42 | // Argument was in wiki link syntax. 43 | $content = jQuery(document.createTextNode(arg.text)); 44 | passage = arg.link; 45 | } 46 | 47 | return { 48 | passage, 49 | $content, 50 | setFn: arg.setFn, 51 | }; 52 | } 53 | 54 | function createLink({passage, $content, setFn}) { 55 | const $link = jQuery(Wikifier.createInternalLink( 56 | null, 57 | passage, 58 | null, 59 | ((passage, fn) => () => { 60 | if (typeof fn === 'function') { 61 | fn(); 62 | } 63 | })(passage, setFn) 64 | )) 65 | .addClass(`macro-${macroName}`) 66 | .append($content); 67 | 68 | if (passage != null) { // lazy equality for null 69 | $link.attr('data-passage', passage); 70 | 71 | if (Story.has(passage)) { 72 | $link.addClass('link-internal'); 73 | 74 | if (Config.addVisitedLinkClass && State.hasPlayed(passage)) { 75 | $link.addClass('link-visited'); 76 | } 77 | } else { 78 | $link.addClass('link-broken'); 79 | } 80 | } else { 81 | $link.addClass('link-internal'); 82 | } 83 | 84 | return $link; 85 | } 86 | 87 | function isLink(arg) { 88 | return arg && jQuery.isPlainObject(arg) && ('link' in arg); 89 | } 90 | 91 | Macro.add(macroName, { 92 | handler() { 93 | if (this.args.length === 0) { 94 | return this.error(`no ${macroName} links specified`); 95 | } 96 | 97 | const currentPassage = passage(); 98 | 99 | const links = []; 100 | 101 | if (this.args.length === 1 && typeof this.args[0] === 'string') { 102 | // single string argument: find all passages with given tag and use them as navigation 103 | const passages = Story.lookupWith(p => p.tags.includes(this.args[0])) 104 | .map(p => p.title); 105 | for (const passageTitle of passages) { 106 | if (passageTitle !== currentPassage) { 107 | links.push({ 108 | $content: jQuery(document.createTextNode(passageTitle)), 109 | passage: passageTitle, 110 | }); 111 | } 112 | } 113 | } else { 114 | for (let i = 0, len = this.args.length; i < len; i++) { 115 | const arg = this.args[i]; 116 | if (isLink(arg)) { 117 | const parsed = parseLinkArg(arg); 118 | if (parsed.passage === currentPassage) { 119 | continue; 120 | } 121 | 122 | const nextArg = this.args[i + 1]; 123 | 124 | if (i < len - 1 && !isLink(nextArg)) { // next is not a link 125 | if (nextArg) { 126 | links.push(parsed); 127 | i++; // skip next round 128 | } 129 | } else { 130 | links.push(parsed); 131 | } 132 | } 133 | } 134 | } 135 | 136 | const $output = jQuery(this.output); 137 | 138 | links.forEach((link) => { 139 | $output.append(renderLinkWrapper(createLink(link))); 140 | }); 141 | }, 142 | }); 143 | }()); -------------------------------------------------------------------------------- /iconcheckbox.js: -------------------------------------------------------------------------------- 1 | (function iconcheckMacro() { 2 | // usage: 3 | // <>toggle value<> 4 | // <><> 5 | 'use strict'; 6 | /* globals version, Macro, Wikifier, State */ 7 | 8 | const macroName = 'iconcheck'; 9 | 10 | if (!version || !version.title || 'SugarCube' !== version.title || !version.major || version.major < 2) { 11 | throw new Error(`<<${macroName}>> macro requires SugarCube 2.0 or greater, aborting load`); 12 | } 13 | 14 | version.extensions.abbr = {major: 1, minor: 0, revision: 0}; 15 | 16 | const clsPrefix = 'iconcheck'; 17 | 18 | const styles = ` 19 | .${macroName} { 20 | cursor: pointer; 21 | } 22 | .${macroName} input { 23 | visibility: hidden; 24 | } 25 | 26 | .${macroName} input + span::before { 27 | font-family: tme-fa-icons; 28 | content: '\\e830\\00a0'; 29 | } 30 | 31 | .${macroName} input:checked + span::before { 32 | content: '\\e831\\00a0'; 33 | } 34 | `; 35 | 36 | jQuery('head').append(``); 37 | 38 | Macro.add(macroName, { 39 | tags: null, 40 | handler () { 41 | const {args, payload} = this; 42 | // Ensure that the variable name argument is a string. 43 | if (typeof args[0] !== 'string') { 44 | return this.error('variable name argument is not a string'); 45 | } 46 | 47 | let varName = args[0].trim(); 48 | 49 | // Try to ensure that we receive the variable's name (incl. sigil), not its value. 50 | if (varName[0] !== '$' && varName[0] !== '_') { 51 | return this.error(`variable name "${args[0]}" is missing its sigil ($ or _)`); 52 | } 53 | 54 | varName = varName.substring(1); 55 | 56 | let onChange = null; 57 | if (args[3]) { 58 | if (typeof args[3] === 'function') { 59 | onChange = args[3]; 60 | } else if (typeof args[3] === 'string' && window[args[3]]) { 61 | onChange = window[args[3]]; 62 | } 63 | } 64 | 65 | 66 | const $span = jQuery(''); 67 | const $input = jQuery(``); 68 | $input.on('change', () => { 69 | const value = $input.prop('checked'); 70 | State.variables[varName] = value; 71 | $span.html(getLabel()); 72 | 73 | if (onChange) { 74 | onChange(value); 75 | } 76 | }); 77 | 78 | function getLabel() { 79 | if (args.length >= 3) { 80 | return State.variables[varName] ? args[1] : args[2]; 81 | } else { 82 | return payload[0].contents; 83 | } 84 | } 85 | 86 | new Wikifier($span, getLabel()); 87 | 88 | const $label = jQuery(``); 89 | 90 | $input.appendTo($label); 91 | $span.appendTo($label); 92 | 93 | $label.appendTo(this.output); 94 | }, 95 | }); 96 | }()); -------------------------------------------------------------------------------- /inventory.js: -------------------------------------------------------------------------------- 1 | (function inventoryUtil() { 2 | 'use strict'; 3 | 4 | // requires onPassagePresent.js 5 | 6 | // Utility functions to manage inventory appearance. 7 | // Inventory is displayed as PassageHeader (or PassageFooter) and consists of button that opens 8 | // some kind of overlay. This button should have `js-toggleInventory` class. Overlay should have 9 | // `js-inventory` class and open by assigning `open` class. 10 | 11 | const $body = jQuery('body'); 12 | const $doc = jQuery(document); 13 | 14 | const inventory = { 15 | pingTimeout: 3500, // depends on how long animation is 16 | beaconInterval: 4000, 17 | pingClassName: 'ping-inventory', 18 | 19 | setup({pingTimeout = this.pingTimeout, beaconInterval = this.beaconInterval, pingClassName = this.pingClassName} = {}) { 20 | Object.assign(this, { 21 | pingTimeout, 22 | beaconInterval, 23 | pingClassName, 24 | }); 25 | }, 26 | 27 | onInventoryClick(event) { 28 | jQuery(event.target) 29 | .closest('.js-inventory') // we need to get this element every time 30 | .toggleClass('open'); 31 | }, 32 | 33 | ping(className, timeout) { 34 | $body.addClass(className); 35 | setTimeout(() => { 36 | $body.removeClass(className); 37 | }, timeout); 38 | }, 39 | 40 | hilite({className = this.pingClassName, timeout = this.pingTimeout} = {}) { 41 | window.scUtils.onPassagePresent(() => { 42 | this.ping(className, timeout); 43 | }); 44 | }, 45 | 46 | beacon({className = this.pingClassName, timeout = this.pingTimeout, interval = this.beaconInterval} = {}) { 47 | const intervalId = setInterval(() => { 48 | this.hilite({className, timeout}); 49 | }, interval); 50 | 51 | // stop beaconing when moved to another passage 52 | $doc.on(':passagestart', () => { 53 | clearInterval(intervalId); 54 | }); 55 | 56 | return intervalId; 57 | }, 58 | }; 59 | 60 | $body.on('click', '.js-toggleInventory', inventory.onInventoryClick); 61 | 62 | window.scUtils = Object.assign( 63 | window.scUtils || {}, 64 | { 65 | inventory, 66 | } 67 | ); 68 | }()); -------------------------------------------------------------------------------- /journal.js: -------------------------------------------------------------------------------- 1 | (function journalMacros() { 2 | /** 3 | <>Lives on North Pole (this is journal entry content)<> 4 | <>Has 4 reindeers<> 5 | <>Gift giver (this serves as optional title)<> 6 | // renders all entries in order they were entered 7 | 8 | <>Doesn't exist!!!<> 9 | <><> 10 | // Will show only one entry 11 | 12 | <>(Nothing will be shown when journaldisplay called)<> 13 | Note that you need to have exactly 3 arguments for this to work 14 | 15 | All arguments are optional and defaults to empty strings 16 | <>Have all journal entries in one place<> 17 | <><> 18 | 19 | Entries content gets rendered when <> is used, not when they are added: 20 | <> 21 | <>I like $melike!<> 22 | <> 23 | <>Me<> 24 | 25 | Internally, all entries are stored in `State.variables.journal`. 26 | */ 27 | 'use strict'; 28 | 29 | /* globals version, State, Macro */ 30 | 31 | if (!version || !version.title || 'SugarCube' !== version.title || !version.major || version.major < 2) { 32 | throw new Error('<> macros family requires SugarCube 2.0 or greater, aborting load'); 33 | } 34 | 35 | if (!State.variables.journal) { 36 | State.variables.journal = {}; 37 | } 38 | 39 | function ensureNameType(args) { 40 | let name = args[0] || ''; 41 | let type = args[1] || ''; 42 | 43 | if (name.startsWith('$')) { 44 | name = State.variables.journal[name.slice(1)]; 45 | } 46 | 47 | if (type.startsWith('$')) { 48 | type = State.variables.journal[type.slice(1)]; 49 | } 50 | 51 | if (!State.variables.journal[type]) { 52 | State.variables.journal[type] = {}; 53 | } 54 | 55 | return {name, type}; 56 | } 57 | 58 | Macro.add('journaladd', { 59 | tags: null, 60 | handler () { 61 | const {name, type} = ensureNameType(this.args); 62 | const entry = this.payload[0].contents.trim(); 63 | 64 | if (!State.variables.journal[type][name]) { 65 | State.variables.journal[type][name] = []; 66 | } 67 | 68 | State.variables.journal[type][name].push(entry); 69 | }, 70 | }); 71 | 72 | Macro.add('journalreplace', { 73 | tags: null, 74 | handler () { 75 | const {name, type} = ensureNameType(this.args); 76 | 77 | if (this.args.length === 3 && this.args[2] === true) { 78 | State.variables.journal[type][name] = []; 79 | } else { 80 | State.variables.journal[type][name] = [this.payload[0].contents]; 81 | } 82 | }, 83 | }); 84 | 85 | function getTitle(payload) { 86 | return payload[0].contents || ''; 87 | } 88 | 89 | function renderJournalSection(title, entries) { 90 | const ul = jQuery('
    '); 91 | entries.forEach((entry) => jQuery('
  • ').wiki(entry).appendTo(ul)); 92 | jQuery(`
    ${title}
    `).insertBefore(ul); 93 | return ul; 94 | } 95 | 96 | Macro.add('journaldisplay', { 97 | tags: null, 98 | handler () { 99 | const {name, type} = ensureNameType(this.args); 100 | 101 | const title = getTitle(this.payload); 102 | const entries = State.variables.journal[type][name]; 103 | 104 | if (entries && entries.length) { 105 | const out = renderJournalSection(title, entries); 106 | out.appendTo(this.output); 107 | } 108 | }, 109 | }); 110 | }()); -------------------------------------------------------------------------------- /l10n-ru.js: -------------------------------------------------------------------------------- 1 | (function l10nRu() { 2 | 'use strict'; 3 | 4 | l10nStrings = { // eslint-disable-line no-undef, no-global-assign 5 | identity : 'игры', 6 | aborting : 'Идет отмена', 7 | cancel : 'Отменить', 8 | close : 'Закрыть', 9 | ok : 'ОК', 10 | 11 | errorTitle : 'Ошибка', 12 | errorNonexistentPassage : 'Параграф "{passage}" не существует', 13 | errorSaveMissingData : 'в сохранении нет необходимых данных. Сохранение было повреждено или загружен неверный файл', 14 | errorSaveIdMismatch : 'сохранение от другой {identity}', 15 | 16 | _warningIntroLacking : 'Приносим извинения! В вашем браузере отсутствуют либо выключены необходимые функции', 17 | _warningOutroDegraded : ', так что включен ограниченный режим. Вы можете продолжать, но кое-что может работать некорректно.', 18 | warningNoWebStorage : '{_warningIntroLacking} (Web Storage API){_warningOutroDegraded}', 19 | warningDegraded : '{_warningIntroLacking} (необходимые для {identity}){_warningOutroDegraded}', 20 | 21 | debugViewTitle : 'Режим отладки', 22 | debugViewToggle : 'Переключить режим отладки', 23 | 24 | uiBarToggle : 'Открыть/закрыть панель навигации', 25 | uiBarBackward : 'Назад по истории {identity}', 26 | uiBarForward : 'Вперед по истории {identity}', 27 | uiBarJumpto : 'Перейти в определенную точку истории {identity}', 28 | 29 | jumptoTitle : 'Перейти на', 30 | jumptoTurn : 'Ход', 31 | jumptoUnavailable : 'В данный момент нет точек для перехода\u2026', 32 | 33 | savesTitle : 'Сохранения', 34 | savesDisallowed : 'На этом параграфе сохранение запрещено.', 35 | savesEmptySlot : '— пустой слот —', 36 | savesIncapable : '{_warningIntroLacking}, так что сохранения невозможны в текущей сессии', 37 | savesLabelAuto : 'Автосохранение', 38 | savesLabelDelete : 'Автосохранение', 39 | savesLabelExport : 'Сохранить на диск\u2026', 40 | savesLabelImport : 'Загрузить с диска\u2026', 41 | savesLabelLoad : 'Загрузить', 42 | savesLabelClear : 'Удалить все', 43 | savesLabelSave : 'Сохранить', 44 | savesLabelSlot : 'Слот', 45 | savesSavedOn : 'Сохранено: ', 46 | savesUnavailable : 'Слоты сохранения не обнаружены\u2026', 47 | savesUnknownDate : 'неизвестно', 48 | 49 | settingsTitle : 'Настройки', 50 | settingsOff : 'Выкл.', 51 | settingsOn : 'Вкл.', 52 | settingsReset : 'По умолчанию', 53 | 54 | restartTitle : 'Начать с начала', 55 | restartPrompt : 'Вы уверены, что хотите начать сначала? Несохраненный прогресс будет утерян.', 56 | 57 | shareTitle : 'Поделиться', 58 | 59 | autoloadTitle : 'Автосохранение', 60 | autoloadCancel : 'Начать заново', 61 | autoloadOk : 'Загрузить сохранение', 62 | autoloadPrompt : 'Найдено автосохранение. Загрузить его или начать с начала?', 63 | 64 | macroBackText : 'Назад', 65 | macroReturnText : 'Вернуться', 66 | }; 67 | }()); 68 | -------------------------------------------------------------------------------- /linkif.js: -------------------------------------------------------------------------------- 1 | (function linkifMacro() { 2 | 'use strict'; 3 | /* globals version, Macro, Story, Config, State, Wikifier, Engine */ 4 | 5 | const macroName = 'linkif'; 6 | 7 | if (!version || !version.title || 'SugarCube' !== version.title || !version.major || version.major < 2) { 8 | throw new Error(`<<${macroName}>> macro requires SugarCube 2.0 or greater, aborting load`); 9 | } 10 | 11 | version.extensions[macroName] = { major: 1, minor: 0, revision: 0 }; 12 | 13 | function has(obj, prop) { 14 | return Object.prototype.hasOwnProperty.call(obj, prop); 15 | } 16 | 17 | function parseLinkArg(arg) { 18 | let passage; 19 | let $content; 20 | if (arg.isImage) { 21 | // Argument was in wiki image syntax. 22 | $content = jQuery(document.createElement('img')) 23 | .attr('src', arg.source); 24 | 25 | if (has(arg, 'passage')) { 26 | $content.attr('data-passage', arg.passage); 27 | } 28 | 29 | if (has(arg,'title')) { 30 | $content.attr('title', arg.title); 31 | } 32 | 33 | if (has(arg, 'align')) { 34 | $content.attr('align', arg.align); 35 | } 36 | 37 | passage = arg.link; 38 | } else { 39 | // Argument was in wiki link syntax. 40 | $content = jQuery(document.createTextNode(arg.text)); 41 | passage = arg.link; 42 | } 43 | 44 | return { 45 | passage, 46 | $content, 47 | }; 48 | } 49 | 50 | Macro.add(macroName, { 51 | tags: null, 52 | handler() { 53 | if (this.args.length !== 2) { 54 | return this.error(`${macroName} only accepts wiki-syntax: [[link text|Passage name]] and a variable/expression`); 55 | } 56 | 57 | const {passage, $content} = parseLinkArg(this.args[0]); 58 | const isLink = this.args[1]; 59 | 60 | const $link = jQuery(document.createElement( isLink ? 'a' : 'span')); 61 | 62 | $content.appendTo($link); 63 | $link.append($content); 64 | $link.addClass(`macro-${this.name}`); 65 | 66 | if (isLink) { 67 | if (passage != null) { // lazy equality for null 68 | $link.attr('data-passage', passage); 69 | 70 | if (Story.has(passage)) { 71 | $link.addClass('link-internal'); 72 | 73 | if (Config.addVisitedLinkClass && State.hasPlayed(passage)) { 74 | $link.addClass('link-visited'); 75 | } 76 | } else { 77 | $link.addClass('link-broken'); 78 | } 79 | } else { 80 | $link.addClass('link-internal'); 81 | } 82 | 83 | $link 84 | .ariaClick({ 85 | namespace: '.macros', 86 | one: passage != null, // lazy equality for null 87 | }, this.createShadowWrapper( 88 | this.payload[0].contents !== '' 89 | ? () => Wikifier.wikifyEval(this.payload[0].contents.trim()) 90 | : null, 91 | passage != null // lazy equality for null 92 | ? () => Engine.play(passage) 93 | : null, 94 | )); 95 | } 96 | 97 | $link.appendTo(this.output); 98 | }, 99 | }); 100 | }()); -------------------------------------------------------------------------------- /linkonce.js: -------------------------------------------------------------------------------- 1 | (function linkonceMacro() { 2 | 'use strict'; 3 | /* globals version, Macro, visited, Story, Config, Wikifier, Engine, State */ 4 | 5 | const macroName = 'linkonce'; 6 | 7 | if (!version || !version.title || 'SugarCube' !== version.title || !version.major || version.major < 2) { 8 | throw new Error(`<<${macroName}>> macro requires SugarCube 2.0 or greater, aborting load`); 9 | } 10 | 11 | version.extensions[macroName] = { major: 1, minor: 0, revision: 0 }; 12 | 13 | function has(obj, prop) { 14 | return Object.prototype.hasOwnProperty.call(obj, prop); 15 | } 16 | 17 | function parseLinkArg(arg) { 18 | let passage; 19 | let $content; 20 | if (arg.isImage) { 21 | // Argument was in wiki image syntax. 22 | $content = jQuery(document.createElement('img')) 23 | .attr('src', arg.source); 24 | 25 | if (has(arg, 'passage')) { 26 | $content.attr('data-passage', arg.passage); 27 | } 28 | 29 | if (has(arg,'title')) { 30 | $content.attr('title', arg.title); 31 | } 32 | 33 | if (has(arg, 'align')) { 34 | $content.attr('align', arg.align); 35 | } 36 | 37 | passage = arg.link; 38 | } else { 39 | // Argument was in wiki link syntax. 40 | $content = jQuery(document.createTextNode(arg.text)); 41 | passage = arg.link; 42 | } 43 | 44 | return { 45 | passage, 46 | $content, 47 | }; 48 | } 49 | 50 | Macro.add(macroName, { 51 | tags: null, 52 | handler() { 53 | if (this.args.length === 0) { 54 | return this.error(`no <<${macroName}>> text specified`); 55 | } 56 | 57 | if (this.args.length > 1) { 58 | return this.error('<<${macroName}>> only accepts wiki-syntax: [[link text|Passage name]]'); 59 | } 60 | 61 | const {passage, $content} = parseLinkArg(this.args[0]); 62 | const hasVisited = visited(passage) > 0; 63 | 64 | const $link = jQuery(document.createElement( hasVisited ? 'span' : 'a')); 65 | 66 | $content.appendTo($link); 67 | $link.append($content); 68 | $link.addClass(`macro-${this.name}`); 69 | 70 | if (!hasVisited) { 71 | if (passage != null) { // lazy equality for null 72 | $link.attr('data-passage', passage); 73 | 74 | if (Story.has(passage)) { 75 | $link.addClass('link-internal'); 76 | 77 | if (Config.addVisitedLinkClass && State.hasPlayed(passage)) { 78 | $link.addClass('link-visited'); 79 | } 80 | } else { 81 | $link.addClass('link-broken'); 82 | } 83 | } else { 84 | $link.addClass('link-internal'); 85 | } 86 | 87 | $link 88 | .ariaClick({ 89 | namespace: '.macros', 90 | one: passage != null, // lazy equality for null 91 | }, this.createShadowWrapper( 92 | this.payload[0].contents !== '' 93 | ? () => Wikifier.wikifyEval(this.payload[0].contents.trim()) 94 | : null, 95 | passage != null // lazy equality for null 96 | ? () => Engine.play(passage) 97 | : null, 98 | )); 99 | } 100 | 101 | $link.appendTo(this.output); 102 | }, 103 | }); 104 | }()); 105 | -------------------------------------------------------------------------------- /menuButton.js: -------------------------------------------------------------------------------- 1 | (function menuButtonUtil() { 2 | 'use strict'; 3 | 4 | // Utility functions to create buttons in dock menu. 5 | // scUtils.createPassageButton creates button which opens dialog displaying passage with given name. 6 | // scUtils.createHandlerButton creates button which calls given handler. 7 | // Both methods return {button, style} objects with jQuery-wrapped references to created elements 8 | 9 | /* globals Story, Dialog */ 10 | 11 | // save some DOM references for later use 12 | const $head = jQuery('head'); 13 | const $menuCore = jQuery('#menu-core'); 14 | 15 | function createButton(id, label, onClick) { 16 | const buttonTemplate = `
  • ${label}
  • `; 17 | const $button = jQuery(buttonTemplate); 18 | 19 | $button.ariaClick(onClick); 20 | $button.appendTo($menuCore); 21 | 22 | return $button; 23 | } 24 | 25 | function createMultiButton(id, mainLabel, labels, onClick) { 26 | const buttonTemplate = ` 27 |
  • 28 | ${mainLabel ? `
    ${mainLabel}
    ` : ''} 29 |
    30 | ${labels.map(label => `${label}`).join('')} 31 |
    32 |
  • `; 33 | const $button = jQuery(buttonTemplate); 34 | $button.on('click', 'a', (event) => { 35 | const index = jQuery(event.currentTarget).index(); 36 | onClick(event, index); 37 | }); 38 | 39 | if (jQuery('style#multi-button-style').length === 0) { 40 | const styles = ` 41 | .multiButton .mainLabel { 42 | text-transform: uppercase; 43 | } 44 | .multiButton .buttons { 45 | display: flex; 46 | } 47 | .multiButton .buttons a { 48 | flex-grow: 1; 49 | } 50 | .multiButton .buttons a[disabled] { 51 | opacity: 0.6; 52 | pointer-events: none; 53 | } 54 | .multiButton .buttons a.active { 55 | border-color: currentColor !important; 56 | } 57 | `; 58 | 59 | const $style = jQuery(``); 60 | $style.appendTo($head); 61 | } 62 | 63 | $button.appendTo($menuCore); 64 | 65 | return {button: $button}; 66 | } 67 | 68 | function createButtonStyle(id, iconContent) { 69 | const styles = ` 70 | #menu-core #${id} a::before { 71 | ${iconContent ? `content: '${iconContent}'` : ''}; 72 | } 73 | `; 74 | 75 | const $style = jQuery(``); 76 | $style.appendTo($head); 77 | 78 | return $style; 79 | } 80 | 81 | function createDlgFromPassage(passageName, title = passageName) { 82 | const content = Story.get(passageName).processText(); 83 | 84 | Dialog.setup(title); 85 | Dialog.wiki(content); 86 | Dialog.open(); 87 | } 88 | 89 | /** 90 | * Creates button in UI dock opening given passage. 91 | * @param {string} label Button label 92 | * @param {string} iconContent Some UTF sequence, like `\\e809\\00a0` 93 | * @param {string} passageName Passage name to display in dialogue 94 | * @return {{button: jQuery, style: jQuery}} 95 | */ 96 | function createPassageButton(label, iconContent, passageName) { 97 | const id = `menu-item-${passageName}`; 98 | 99 | return { 100 | button: createButton(id, label, () => createDlgFromPassage(passageName, label)), 101 | style: createButtonStyle(id, iconContent), 102 | }; 103 | } 104 | 105 | /** 106 | * Creates button in UI dock which calls `handler` when clicked. 107 | * @param {string} label Button label 108 | * @param {string} iconContent Some UTF sequence, like `\e809\00a0` 109 | * @param {string} shortName any unique identifier, only letters, digits, dashes, underscore 110 | * @param {Function} handler Function to call on click/tap 111 | * @return {{button: jQuery, style: jQuery}} 112 | */ 113 | function createHandlerButton(label, iconContent, shortName, handler) { 114 | const id = `menu-item-${shortName}`; 115 | 116 | return { 117 | button: createButton(id, label, handler), 118 | style: createButtonStyle(id, iconContent), 119 | }; 120 | } 121 | 122 | window.scUtils = Object.assign( 123 | window.scUtils || {}, 124 | { 125 | createDlgFromPassage, 126 | createPassageButton, 127 | createHandlerButton, 128 | createMultiButton, 129 | } 130 | ); 131 | }()); -------------------------------------------------------------------------------- /more.js: -------------------------------------------------------------------------------- 1 | (function moreMacro() { 2 | // usage: <>content<> 3 | 'use strict'; 4 | /* globals version, Macro */ 5 | 6 | const macroName = 'more'; 7 | 8 | if (!version || !version.title || 'SugarCube' !== version.title || !version.major || version.major < 2) { 9 | throw new Error(`<<${macroName}>> macro requires SugarCube 2.0 or greater, aborting load`); 10 | } 11 | 12 | version.extensions[macroName] = {major: 1, minor: 0, revision: 0}; 13 | 14 | const clsPrefix = `more${macroName}`; 15 | 16 | class Tooltiper { 17 | constructor(options) { 18 | this.options = Object.assign({}, { 19 | container: document.body, 20 | clsPrefix, 21 | }, options); 22 | 23 | this.container = typeof this.options.container === 'string' ? document.querySelector(this.options.container) : this.options.container; 24 | 25 | this.target = null; 26 | 27 | this.onMouseOver = this.onMouseOver.bind(this); 28 | this.onMouseOut = this.onMouseOut.bind(this); 29 | 30 | this.addListeners(); 31 | } 32 | 33 | createTooltip() { 34 | this.el = document.createElement('div'); 35 | this.el.className = this.options.clsPrefix; 36 | this.container.appendChild(this.el); 37 | } 38 | 39 | onMouseOver(event) { 40 | const target = event.target.closest(`[data-${this.options.clsPrefix}]`); 41 | 42 | if (target) { 43 | if (this.target !== target) { 44 | this.target = target; 45 | } 46 | 47 | this.show(); 48 | } else { 49 | this.hide(); 50 | } 51 | } 52 | 53 | onMouseOut(event) { 54 | if (event.relatedTarget === this.el) { 55 | return; 56 | } 57 | 58 | this.hide(); 59 | } 60 | 61 | show() { 62 | if (!this.el) { 63 | this.createTooltip(); 64 | } 65 | 66 | const targetRect = this.target.getBoundingClientRect(); 67 | 68 | this.el.innerHTML = this.target.dataset[clsPrefix]; 69 | 70 | const position = this.getPosition(targetRect); 71 | 72 | this.el.style.minWidth = targetRect.width + 'px'; 73 | this.el.style.top = position.top + pageYOffset + targetRect.height + 'px'; 74 | this.el.style.left = position.left + pageXOffset + 'px'; 75 | 76 | this.container.appendChild(this.el); 77 | this.el.classList.add('visible'); 78 | } 79 | 80 | getPosition(rect) { 81 | let {top, left} = rect; 82 | const {offsetHeight, offsetWidth} = this.el; 83 | 84 | if (rect.top + rect.height + offsetHeight > window.innerHeight) { 85 | top = rect.top - (offsetHeight + rect.height); 86 | } 87 | 88 | if (rect.left + rect.width + offsetWidth > window.innerWidth) { 89 | left = rect.left - Math.abs(rect.width - offsetWidth); 90 | 91 | if (left < 0) { 92 | left = 0; 93 | } 94 | } 95 | 96 | return {top, left}; 97 | } 98 | 99 | hide() { 100 | if (this.el) { 101 | this.el.classList.remove('visible'); 102 | this.el.remove(); 103 | } 104 | } 105 | 106 | addListeners() { 107 | this.container.addEventListener('mouseover', this.onMouseOver); 108 | this.container.addEventListener('mouseout', this.onMouseOut); 109 | } 110 | 111 | removeListeners() { 112 | this.container.removeEventListener('mouseover', this.onMouseOver); 113 | this.container.removeEventListener('mouseout', this.onMouseOut); 114 | } 115 | 116 | destroy() { 117 | this.removeListeners(); 118 | } 119 | } 120 | 121 | const styles = ` 122 | .${clsPrefix} { 123 | position: absolute; 124 | z-index: 1000; 125 | opacity: 0; 126 | background-color: inherit; 127 | border: 1px solid currentColor; 128 | max-width: 90%; 129 | padding: 4px 10px; 130 | font-size: 90%; 131 | transition: opacity 150ms linear; 132 | pointer-events: auto; 133 | box-shadow: 0px 0px 1em 0px; 134 | } 135 | 136 | body #story, body #story .${clsPrefix} { 137 | background-color: inherit; 138 | } 139 | 140 | .${clsPrefix}.visible { 141 | opacity: 1; 142 | pointer-events: none; 143 | } 144 | 145 | [data-${clsPrefix}] { 146 | border-bottom: 1px dotted currentColor; 147 | cursor: pointer; 148 | } 149 | `; 150 | 151 | jQuery('head').append(``); 152 | 153 | new Tooltiper({container: '#story'}); 154 | 155 | Macro.add(macroName, { 156 | tags: null, 157 | handler () { 158 | const more = jQuery(``); 159 | more.wiki(this.payload[0].contents); 160 | more.appendTo(this.output); 161 | }, 162 | }); 163 | }()); -------------------------------------------------------------------------------- /mute.js: -------------------------------------------------------------------------------- 1 | (function muteBtn() { 2 | // Note: volumeButtons.js provides more functionality 3 | // requires menuButton.js 4 | // 5 | // Adds 'mute' toggle button to UI Bar 6 | 7 | 'use strict'; 8 | 9 | /* globals l10nStrings, scUtils, storage */ 10 | 11 | let mute = storage.get('mute') === 'true'; 12 | 13 | function renderMute() { 14 | storage.get('mute', mute.toString()); 15 | document.documentElement.classList.toggle('mute', mute); 16 | jQuery.wiki(`<>`); // use engine API instead of undocumented access 17 | } 18 | 19 | function handler() { 20 | mute = !mute; 21 | 22 | renderMute(); 23 | } 24 | 25 | const { style } = scUtils.createHandlerButton(l10nStrings.uiBarMute || 'Sound', '\\e843\\00a0', 'mute', handler); 26 | const styleId = style.attr('id').replace(/-style$/, ''); 27 | 28 | style.text(` 29 | #menu-core #${styleId} a::before { 30 | content: '\\e843\\00a0'; 31 | } 32 | .mute #menu-core #${styleId} a::before { 33 | content: '\\e842\\00a0'; 34 | } 35 | `); 36 | 37 | renderMute(); 38 | }()); -------------------------------------------------------------------------------- /onPassagePresent.js: -------------------------------------------------------------------------------- 1 | (function onPassagePresentUtil() { 2 | 'use strict'; 3 | 4 | /* globals Engine */ 5 | 6 | const $doc = jQuery(document); 7 | 8 | function onPassagePresent(callback) { 9 | if (Engine.isRendering()) { // if we're calling this fn from <> or <