├── .eslintignore
├── .eslintrc
├── .gitignore
├── LICENSE.txt
├── README.md
├── __mocks__
├── persistent.js
└── state.js
├── __tests__
├── components
│ ├── game
│ │ ├── control.test.js
│ │ ├── index.test.js
│ │ └── sessions.test.js
│ ├── ink.test.js
│ ├── saves.test.js
│ ├── settings.test.js
│ └── sound.test.js
├── index.test.js
└── utils
│ ├── config.test.js
│ ├── emitter.test.js
│ ├── hashcode.test.js
│ ├── interfaces.test.js
│ ├── scene-processors.test.js
│ └── tags.test.js
├── babel.config.js
├── jest.config.js
├── package-lock.json
├── package.json
└── src
├── components
├── game
│ ├── control.js
│ ├── index.js
│ └── sessions.js
├── ink.js
├── saves.js
├── settings.js
└── sound.js
├── index.js
└── utils
├── config.js
├── emitter.js
├── hashcode.js
├── interfaces.js
├── scene-processors.js
├── tags.js
└── to-array.js
/.eslintignore:
--------------------------------------------------------------------------------
1 | node_modules/
2 |
--------------------------------------------------------------------------------
/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "extends": [
3 | "airbnb-base"
4 | ],
5 | "parser": "@babel/eslint-parser",
6 | "rules": {
7 | "comma-dangle": ["error", "never"],
8 | "indent": ["error", 2],
9 | "no-use-before-define": ["error", { "functions": false, "classes": false }],
10 | "arrow-parens": ["error", "always"],
11 | "object-curly-spacing": ["error", "always"],
12 | "object-curly-newline": ["off"],
13 | "function-paren-newline": ["off"],
14 | "max-len": ["error", 120],
15 | "no-param-reassign": ["error", { "props": false }],
16 | "no-multiple-empty-lines": ["off"],
17 | "import/prefer-default-export": ["off"]
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /build/
2 | /coverage/
3 | /node_modules/
4 | .eslintcache
5 | npm-debug.*
6 |
--------------------------------------------------------------------------------
/LICENSE.txt:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright 2023 Serhii "techniX" Mozhaiskyi
4 |
5 | Permission is hereby granted, free of charge, to any person
6 | obtaining a copy of this software and associated documentation
7 | files (the "Software"), to deal in the Software without
8 | restriction, including without limitation the rights to use,
9 | copy, modify, merge, publish, distribute, sublicense,
10 | and/or sell copies of the Software, and to permit persons
11 | to whom the Software is furnished to do so, subject to the
12 | following conditions:
13 |
14 | The above copyright notice and this permission notice
15 | shall be included in all copies or substantial portions
16 | of the Software.
17 |
18 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
19 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
20 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE
21 | AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
22 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
23 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
24 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE
25 | OR OTHER DEALINGS IN THE SOFTWARE.
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Atrament
2 |
3 | `atrament-core` is a framework for choice-based games, built around `inkjs`.
4 |
5 | If you need a ready-to-use library for web applications, check out [atrament-web](https://github.com/technix/atrament-web).
6 |
7 | If you are looking for an example of a web application based on Atrament, check out [atrament-web-ui](https://github.com/technix/atrament-web-ui).
8 |
9 | ## Features
10 |
11 | - Implements game flow: loading Ink story, getting text content, making choices
12 | - Manages global application settings
13 | - Parses tags, and handles some of them (mostly compatible with Inky)
14 | - Auto-observe variables defined with 'observe' global tag
15 | - Manages sound and music via knot tags
16 | - Manages autosaves, checkpoints, and named saves for every game
17 | - Music state is saved and restored along with game state
18 | - All changes affect the internal state
19 |
20 |
21 | ## Installation
22 |
23 | ```npm install @atrament/core```
24 |
25 | ## Tags handled by Atrament
26 |
27 | ### Global tags
28 |
29 | | Tag | Description |
30 | | :-------- | :------------------------- |
31 | | `# observe: varName` | Register variable observer for `varName` Ink variable. The variable value is available in the `vars` section of Atrament state. |
32 | | `# persist: varName` | Save this variable value to persistent storage, and restore it before the game starts. |
33 | | `# autosave: false` | Disables autosaves. |
34 | | `# single_scene` | Store only the last scene in the Atrament state. |
35 | | `# continue_maximally: false` | Pause story after each line. In this mode the `scene` object contains the `canContinue` field, which is set to true if the story can be continued. |
36 |
37 |
38 | ### Knot tags
39 | | Tag | Description |
40 | | :-------- | :------------------------- |
41 | | `# CLEAR` | Clear the scenes list before saving the current scene to Atrament state. |
42 | | `# AUDIO: sound.mp3` | Play sound (once). |
43 | | `# AUDIOLOOP: music.mp3` | Play background music (looped). There can be only one background music track. |
44 | | `# AUDIOLOOP: false` | Stop playing all background music. |
45 | | `# PLAY_SOUND: sound.mp3` | Play sound (once). |
46 | | `# STOP_SOUND: sound.mp3` | Stop playing specific sound. |
47 | | `# STOP_SOUND` | Stop playing all sounds. |
48 | | `# PLAY_MUSIC: music.mp3` | Play background music (looped). There can be multiple background music tracks, played simultaneously. |
49 | | `# STOP_MUSIC: music.mp3` | Stop playing specific background music. |
50 | | `# STOP_MUSIC` | Stop playing all background music. |
51 | | `# CHECKPOINT` | Save the game to the default checkpoint. |
52 | | `# CHECKPOINT: checkpointName` | Save the game to checkpoint `checkpointName`. |
53 | | `# SAVEGAME: saveslot` | Save the game to `saveslot`. |
54 | | `# RESTART` | Start game from beginning. |
55 | | `# RESTART_FROM_CHECKPOINT` | Restart the game from the latest checkpoint. |
56 | | `# RESTART_FROM_CHECKPOINT: checkpointName` | Restart game from named checkpoint. |
57 | | `# IMAGE: picture.jpg` | Adds specified image to the `images` attribute of the scene and paragraph. It can be used to preload image files for the scene. |
58 |
59 | Note: For sound effects, please use either AUDIO/AUDIOLOOP or PLAY_SOUND/PLAY_MUSIC/STOP_SOUND/STOP_MUSIC tags. Combining them may lead to unexpected side effects.
60 |
61 | ### Choice tags
62 | | Tag | Description |
63 | | :-------- | :------------------------- |
64 | | `# UNCLICKABLE` | Alternative names: `#DISABLED`, `#INACTIVE`.
Sets `disabled: true` attribute to the choice. |
65 |
66 | ## API Reference
67 |
68 | #### atrament.version
69 |
70 | Atrament version string. Read-only.
71 |
72 | ### Base methods
73 |
74 | #### atrament.defineInterfaces()
75 |
76 | Defines interface modules for:
77 | - **loader**: ink file loader
78 | - **persistent**: persistent storage
79 | - **sound**: sound control (optional)
80 | - **state**: state management
81 |
82 | Interfaces should be defined **before** calling any other methods.
83 |
84 | ```
85 | atrament.defineInterfaces({
86 | loader: interfaceLoader,
87 | persistent: persistentInterface,
88 | sound: soundInterface,
89 | state: stateInterface
90 | });
91 | ```
92 |
93 | #### atrament.init(Story, configuration)
94 |
95 | Initialize the game engine. Takes two parameters:
96 | - **Story** is an inkjs constructor, imported directly from inkjs
97 | - **configuration** is a configuration object:
98 | - **applicationID** should be a unique string. It is used to distinguish persistent storage of your application.
99 | - **settings** is a default settings object. These settings are immediately applied.
100 |
101 | ```
102 | import {Story} from 'inkjs';
103 | const config = {
104 | applicationID: 'your-application-id',
105 | settings: {
106 | mute: true,
107 | volume: 10,
108 | fullscreen: true
109 | }
110 | }
111 | atrament.init(Story, config);
112 | ```
113 |
114 | #### atrament.on(event, listener)
115 |
116 | Subscribe to specific Atrament events. The **listener** function is called with a single argument containing event parameters.
117 |
118 | You can subscribe to all Atrament events:
119 | ```
120 | atrament.on('*', (event, args) => { ... });
121 | ```
122 |
123 | #### atrament.off(event, listener)
124 |
125 | Unsubscribe specified listener from the Atrament event.
126 |
127 | #### atrament.state
128 |
129 | Returns Atrament state interface. Can be used to operate state directly:
130 |
131 | ```
132 | atrament.state.setSubkey('game', 'checkpoint', true);
133 | ```
134 |
135 | #### atrament.store
136 |
137 | Return raw store object. It can be used in hooks, for example:
138 |
139 | ```
140 | const gamestate = useStore(atrament.store);
141 | ```
142 |
143 | #### atrament.interfaces
144 |
145 | Returns raw interface objects. It can be used to operate with them directly.
146 |
147 | ```
148 | const { state, persistent } = atrament.interfaces;
149 | ```
150 |
151 | ### Game methods
152 |
153 | #### async atrament.game.init(path, file, gameID)
154 |
155 | Initialize game object. It is required to perform operations with saves.
156 | Parameters:
157 | - path: path to Ink file
158 | - file: Ink file name
159 | - gameID: optional. If provided, Atrament will use the given ID for save management. Otherwise, it will be generated based on path and filename.
160 |
161 | Event: `'game/init', { pathToInkFile: path, inkFile: file }`
162 |
163 | #### async atrament.game.initInkStory()
164 |
165 | Load Ink file and initialize Ink Story object. Then it updates game metadata and initializes variable observers.
166 |
167 | Event: `'game/initInkStory'`
168 |
169 | #### atrament.game.getSaveSlotKey({ name, type })
170 |
171 | Returns save slot identifier for given save name and type.
172 | Possible save types: `atrament.game.SAVE_GAME`, `atrament.game.SAVE_CHECKPOINT`, `atrament.game.SAVE_AUTOSAVE`. For autosaves, the `name` parameter should be omitted.
173 | The returned value can be used as a `saveslot` parameter.
174 |
175 | #### async atrament.game.start(saveslot)
176 |
177 | If the game is started for the first time, or the initialized game is not the same as the current one - call `initInkStory` first.
178 | Clears game state, and gets initial data for variable observers.
179 | If `saveslot` is defined, load state from specified save.
180 |
181 | Event: `'game/start', { saveSlot: saveslot }`
182 |
183 | #### async atrament.game.resume()
184 |
185 | Resume saved game:
186 | - if autosave exists, resume from autosave
187 | - if checkpoints exist, resume from the newest checkpoint
188 | - otherwise, start a new game
189 |
190 | Event: `'game/resume', { saveSlot: saveslot }`
191 |
192 | #### async atrament.game.canResume()
193 |
194 | Returns save slot identifier if game can be resumed.
195 |
196 | Event: `'game/canResume', { saveSlot: saveslot }`
197 |
198 | #### async atrament.game.restart(saveslot)
199 |
200 | Restart the game from the specified save slot (if `saveslot` is not defined, start a new game).
201 |
202 | Event: `'game/restart', { saveSlot: saveslot }`
203 |
204 | #### async atrament.game.restartAndContinue(saveslot)
205 |
206 | Run `atrament.game.restart`, then run `atrament.game.continueStory()` to regenerate game content.
207 |
208 | #### async atrament.game.load(saveslot)
209 |
210 | Load game state from specified save slot.
211 |
212 | Event: `'game/load', saveslot`
213 |
214 | #### async atrament.game.saveGame(name)
215 |
216 | Save game state to save slot.
217 |
218 | Event: `'game/save', { type: 'game', name }`
219 |
220 | #### async atrament.game.saveCheckpoint(name)
221 |
222 | Save the game state to the checkpoint.
223 |
224 | Event: `'game/save', { type: 'checkpoint', name }`
225 |
226 | #### async atrament.game.saveAutosave()
227 |
228 | Save the game state to autosave slot.
229 |
230 | Event: `'game/save', { type: 'autosave' }`
231 |
232 | #### async atrament.game.listSaves()
233 |
234 | Returns array of all existing saves for active game.
235 |
236 | Event: `'game/listSaves', savesListArray`
237 |
238 | #### async atrament.game.removeSave(saveslot)
239 |
240 | Removes specified game save slot.
241 |
242 | Event: `'game/removeSave', saveslot`
243 |
244 | #### async atrament.game.existSave(saveslot)
245 |
246 | Returns `true` if specified save slot exists.
247 |
248 | #### atrament.game.continueStory()
249 |
250 | - gets Ink scene content
251 | - run scene processors
252 | - process tags
253 | - updates Atrament state with a scene content
254 |
255 | Event: `'game/continueStory'`
256 |
257 | Event for tag handling: `'game/handleTag', { [tagName]: tagValue }`
258 |
259 | #### atrament.game.makeChoice(id)
260 |
261 | Make a choice in Ink. Wrapper for `atrament.ink.makeChoice`.
262 |
263 | #### atrament.game.defineSceneProcessor(processorFunction)
264 |
265 | Register `processorFunction` for scene post-processing. It takes the `scene` object as an argument by reference:
266 |
267 | ```
268 | function processCheckpoint(scene) {
269 | if (scene.tags.CHECKPOINT) {
270 | scene.is_checkpoint = true;
271 | }
272 | }
273 | atrament.game.defineSceneProcessor(processCheckpoint);
274 |
275 | ```
276 |
277 | #### atrament.game.getAssetPath(file)
278 |
279 | Returns the full path to asset file (image, sound, music).
280 |
281 | #### atrament.game.clear()
282 |
283 | Method to call at the game end. It stops music, and clears `scenes` and `vars` in the Atrament state.
284 |
285 | Event: `'game/clear'`
286 |
287 | #### atrament.game.reset()
288 |
289 | Method to call at the game end. It calls `atrament.game.clear()`, then clears `metadata` and `game` in Atrament state.
290 |
291 | Event: `'game/reset'`
292 |
293 | #### atrament.game.getSession()
294 |
295 | Returns current game session.
296 |
297 | #### atrament.game.setSession(sessionID)
298 |
299 | Sets current game session. If set to empty value, reset session ID to default.
300 |
301 | Event: `'game/setSession', sessionID`
302 |
303 | #### async atrament.game.getSessions()
304 |
305 | Returns list of existing sessions in a `{ sessionName: numberOfSaves, ... }` format.
306 |
307 | Event: `'game/getSessions', sessionList`
308 |
309 | #### async atrament.game.deleteSession(sessionID)
310 |
311 | Delete all saves for a given session.
312 |
313 | Event: `'game/deleteSession', sessionID`
314 |
315 |
316 | ### Ink methods
317 |
318 | #### atrament.ink.initStory()
319 |
320 | Initializes Ink story with loaded content.
321 |
322 | Event: `'ink/initStory'`
323 | #### atrament.ink.story()
324 |
325 | Returns current Story instance.
326 |
327 | #### atrament.ink.loadState(state)
328 |
329 | Load Ink state from JSON.
330 |
331 | #### atrament.ink.getState()
332 |
333 | Returns current Ink state as JSON object.
334 |
335 | #### atrament.ink.makeChoice(id)
336 |
337 | Wrapper for `Story.ChooseChoiceIndex`.
338 |
339 | Event: `'ink/makeChoice', { id: choiceId }`
340 |
341 | #### atrament.ink.getVisitCount(ref)
342 |
343 | Wrapper for `Story.VisitCountAtPathString`.
344 |
345 | Event: `'ink/getVisitCount', { ref: ref, visitCount: value }`
346 |
347 | #### atrament.ink.evaluateFunction(functionName, argsArray)
348 |
349 | Evaluates Ink function, then returns the result of the evaluation. Wrapper for `Story.EvaluateFunction`.
350 |
351 | Event: `'ink/evaluateFunction', { function: functionName, args: argsArray, result: functionReturnValue }`
352 |
353 | #### atrament.ink.getGlobalTags()
354 |
355 | Returns parsed Ink global tags.
356 |
357 | Event: `'ink/getGlobalTags', { globalTags: globalTagsObject }`
358 |
359 | #### atrament.ink.getVariable(variableName)
360 |
361 | Returns value of specified Ink variable.
362 |
363 | Event: `'ink/getVariable', { name: variableName }`
364 |
365 | #### atrament.ink.getVariables()
366 |
367 | Returns all variables and their values as a key-value object.
368 |
369 | Event: `'ink/getVariables', inkVariablesObject`
370 |
371 | #### atrament.ink.setVariable(variableName, value)
372 |
373 | Sets value of specified Ink variable.
374 |
375 | Event: `'ink/setVariable', { name: variableName, value: value }`
376 |
377 | #### atrament.ink.observeVariable(variableName, observerFunction)
378 |
379 | Registers observer for a specified variable. Wrapper for `Story.ObserveVariable`.
380 |
381 | #### atrament.ink.goTo(ref)
382 |
383 | Go to the specified Ink knot or stitch. Wrapper for `Story.ChoosePathString`.
384 |
385 | Event: `'ink/goTo', { knot: ref }`
386 |
387 | #### atrament.ink.onError(errorCallback)
388 |
389 | When an Ink error occurs, it emits `ink/onError` event and calls the `errorCallback` function with the error event object as an argument.
390 |
391 | Event: `'ink/onError', errorEvent`
392 |
393 | #### atrament.ink.getScene()
394 |
395 | Returns **Scene** object.
396 |
397 | Event: `'ink/getScene', { scene: sceneObject }`
398 |
399 | ### Settings methods
400 |
401 | Application settings for your application. Loading, saving, and setting values changes the `settings` section of the Atrament state.
402 |
403 | However, if you need to perform additional actions when the setting is changed, you can define a handler for it - see below. By default, Atrament handles `mute` and `volume` settings this way, muting and setting sound volume respectively.
404 |
405 | #### async atrament.settings.load()
406 |
407 | Load settings from persistent storage to Atrament state.
408 |
409 | Event: `'settings/load'`
410 |
411 | #### async atrament.settings.save()
412 |
413 | Save settings to persistent storage.
414 |
415 | Event: `'settings/save'`
416 |
417 | #### atrament.settings.get(parameter)
418 |
419 | Returns value of the setting.
420 |
421 | Event: `'settings/get', { name: parameter }`
422 |
423 | #### atrament.settings.set(parameter, value)
424 |
425 | Sets value of setting.
426 |
427 | Event: `'settings/set', { name: parameter, value: value }`
428 |
429 | #### atrament.settings.toggle(parameter)
430 |
431 | Toggles setting (sets `true` to `false` and vice versa).
432 |
433 | #### atrament.settings.defineHandler(parameter, handlerFunction)
434 |
435 | Defines a settings handler.
436 |
437 | For example, you have to run some JavaScript code to toggle fullscreen mode in your app.
438 |
439 | ```
440 | const fullscreenHandler = (oldValue, newValue) => {
441 | // do some actions
442 | }
443 |
444 | atrament.settings.defineHandler('fullscreen', fullscreenHandler);
445 |
446 | // later...
447 |
448 | atrament.toggle('fullscreen');
449 | // or
450 | atrament.set('fullscreen', true);
451 |
452 | // both these methods will change the setting and run the corresponding handler
453 | ```
454 |
455 |
456 | ## Scene object
457 |
458 | ```
459 | {
460 | content: [],
461 | text: [],
462 | tags: {},
463 | choices: [],
464 | images: [],
465 | sounds: [],
466 | music: [],
467 | uuid: Number
468 | }
469 | ```
470 |
471 | | Key | Description |
472 | | :-------- | :------------------------- |
473 | | `content` | Array of Ink paragraphs: `{text: '', tags: {}, images: [], sounds: [], music: []}` |
474 | | `text` | Array of all story text from all paragraphs of this scene |
475 | | `tags` | Array of all tags from all paragraphs of this scene |
476 | | `choices` | Array of choice objects: `{ id: 0, choice: 'Choice Text', tags: {}}` |
477 | | `images` | Array of all images from all paragraphs of this scene |
478 | | `sound` | Array of all sounds from all paragraphs of this scene |
479 | | `music` | Array of all music tracks from all paragraphs of this scene |
480 | | `uuid` | Unique ID of the scene (`Date.now()`) |
481 |
482 |
483 | ## State structure
484 |
485 | ```
486 | {
487 | settings: {},
488 | game: {},
489 | metadata: {},
490 | scenes: [],
491 | vars: {}
492 | }
493 | ```
494 |
495 | | Key | Description |
496 | | :-------- | :------------------------- |
497 | | `settings` | Single-level key-value store for application settings |
498 | | `game` | Single-level game-specific data. Atrament populates the following keys: *$pathToInkFile, $inkFile, $gameUUID* |
499 | | `metadata` | Data loaded from Ink file global tags |
500 | | `scenes` | Array of game scenes |
501 | | `vars` | Names and values of auto-observed variables |
502 |
503 | ## Save structure
504 |
505 | ```
506 | { id, date, state, game, scenes }
507 | ```
508 | | Key | Description |
509 | | :-------- | :------------------------- |
510 | | `id` | Save slot ID |
511 | | `date` | Save timestamp |
512 | | `state` | JSON structure of Ink state |
513 | | `game` | Content of `game` from Atrament state |
514 | | `scenes` | Content of `scenes` from Atrament state |
515 |
516 | Please note that `metadata` and `vars` from the Atrament state are not included in the save. However, they are automatically populated from the Ink state after loading from a save.
517 |
518 |
519 | ## Interfaces
520 |
521 | `atrament-core` uses dependency injection. It uses inkjs `Story` constructor 'as-is', and uses custom interfaces for other libraries.
522 |
523 | There are four interfaces in `atrament-core`. Their implementation is not included, so developers can use `atrament-core` with the libraries they like.
524 |
525 | ### loader
526 | Interface to file operations. The function `init` will be called first, taking the path to the game as a parameter. The function `getAssetPath` should return the full path of a given file. The async function `loadInk` should return the content of a given Ink file, located in the folder defined at the initialization time.
527 |
528 | ```
529 | {
530 | async init(path)
531 | getAssetPath(filename)
532 | async loadInk(filename)
533 | }
534 | ```
535 |
536 | ### persistent
537 | Interface to persistent storage library.
538 |
539 | ```
540 | {
541 | init()
542 | async exists(key)
543 | async get()
544 | async set(key)
545 | async remove(key)
546 | async keys()
547 | }
548 | ```
549 |
550 | ### state
551 | Interface to state management library.
552 |
553 | ```
554 | {
555 | store()
556 | get()
557 | setKey(key, value)
558 | toggleKey(key)
559 | appendKey(key, value)
560 | setSubkey(key, subkey, value)
561 | toggleSubkey(key, subkey)
562 | appendSubkey(key, subkey, value)
563 | }
564 | ```
565 |
566 | ### sound
567 | Interface to sound management library.
568 | ```
569 | {
570 | init(defaultSettings)
571 | mute(flag)
572 | isMuted()
573 | setVolume(volume)
574 | getVolume()
575 | playSound(soundFile)
576 | stopSound(soundFile|undefined)
577 | playMusic(musicFile)
578 | stopMusic(musicFile|undefined)
579 | }
580 | ```
581 |
582 | ## LICENSE
583 |
584 | Atrament is distributed under MIT license.
585 |
586 | Copyright (c) 2023 Serhii "techniX" Mozhaiskyi
587 |
588 | Made with the support of the [Interactive Fiction Technology Foundation](https://iftechfoundation.org/)
589 |
590 |
591 |
--------------------------------------------------------------------------------
/__mocks__/persistent.js:
--------------------------------------------------------------------------------
1 | const gameState = {};
2 | let key = '';
3 |
4 | function dbkey(id) {
5 | return `${key}${id}`;
6 | }
7 |
8 | function reset() {
9 | key = '';
10 | Object.keys(gameState).forEach((k) => delete gameState[k]);
11 | }
12 |
13 | function init(prefix) {
14 | key = `>>${prefix}<<`;
15 | }
16 |
17 | async function exists(id) {
18 | return !!gameState[dbkey(id)];
19 | }
20 |
21 | async function get(id) {
22 | return JSON.parse(gameState[dbkey(id)]);
23 | }
24 |
25 | async function set(id, state) {
26 | gameState[dbkey(id)] = JSON.stringify(state);
27 | }
28 |
29 | async function remove(id) {
30 | delete gameState[dbkey(id)];
31 | }
32 |
33 | async function keys() {
34 | return Object.keys(gameState).filter((k) => k.includes(key)).map((k) => k.replace(key, ''));
35 | }
36 |
37 | export default {
38 | gameState,
39 | reset,
40 | init,
41 | exists,
42 | get,
43 | set,
44 | remove,
45 | keys
46 | };
47 |
--------------------------------------------------------------------------------
/__mocks__/state.js:
--------------------------------------------------------------------------------
1 | const atramentState = {
2 | settings: {},
3 | game: {},
4 | metadata: {},
5 | scenes: [],
6 | vars: {}
7 | };
8 |
9 | function reset() {
10 | atramentState.settings = {};
11 | atramentState.game = {};
12 | atramentState.metadata = {};
13 | atramentState.scenes = [];
14 | atramentState.vars = {};
15 | }
16 |
17 | function store() {
18 | return atramentState;
19 | }
20 |
21 | function get() {
22 | return JSON.parse(JSON.stringify(atramentState));
23 | }
24 |
25 | function setKey(name, value) {
26 | atramentState[name] = value;
27 | }
28 |
29 | function toggleKey(name) {
30 | atramentState[name] = !atramentState[name];
31 | }
32 |
33 | function appendKey(name, value) {
34 | atramentState[name] = [...atramentState[name], value];
35 | }
36 |
37 | function setSubkey(name, subname, value) {
38 | atramentState[name] = { ...atramentState[name], [subname]: value };
39 | }
40 |
41 | function toggleSubkey(name, subname) {
42 | atramentState[name] = { ...atramentState[name], [subname]: !atramentState[name][subname] };
43 | }
44 |
45 | function appendSubkey(name, subname, value) {
46 | atramentState[name] = { ...atramentState[name], [subname]: [...atramentState[name][subname], value] };
47 | }
48 |
49 | export default {
50 | reset,
51 | store,
52 | get,
53 | setKey,
54 | toggleKey,
55 | appendKey,
56 | setSubkey,
57 | toggleSubkey,
58 | appendSubkey
59 | };
60 |
--------------------------------------------------------------------------------
/__tests__/components/game/control.test.js:
--------------------------------------------------------------------------------
1 | /* eslint-env jest */
2 | import mockPersistent from '../../../__mocks__/persistent';
3 | import mockState from '../../../__mocks__/state';
4 |
5 | import { emit } from '../../../src/utils/emitter';
6 | import hashCode from '../../../src/utils/hashcode';
7 |
8 | import ink from '../../../src/components/ink';
9 | import { persistentPrefix, load, existSave, save, SAVE_GAME, getSaveSlotKey } from '../../../src/components/saves';
10 | import { playMusic, stopMusic } from '../../../src/components/sound';
11 |
12 | import { init, loadInkFile, initInkStory, start, clear, reset } from '../../../src/components/game/control';
13 |
14 | let mockGlobalTags;
15 | let mockObserver;
16 | let mockInkContent;
17 | const mockInkState = { inkState: true };
18 | const mockLoader = jest.fn(() => Promise.resolve(mockInkContent));
19 | const mockInitLoader = jest.fn(() => Promise.resolve());
20 | const mockGetAssetPath = jest.fn((file) => `${mockState.get().game.$path}/${file}`);
21 |
22 | jest.mock('../../../src/components/sound', () => ({
23 | playMusic: jest.fn(),
24 | stopMusic: jest.fn()
25 | }));
26 |
27 | jest.mock('../../../src/utils/emitter', () => ({
28 | emit: jest.fn()
29 | }));
30 |
31 | jest.mock('../../../src/components/ink', () => ({
32 | initStory: jest.fn(),
33 | getVariable: jest.fn((v) => `${v}-value`),
34 | setVariable: jest.fn(),
35 | observeVariable: jest.fn((v, handler) => { mockObserver = handler; }),
36 | getGlobalTags: jest.fn(() => mockGlobalTags),
37 | resetStory: jest.fn(),
38 | getState: jest.fn(() => mockInkState),
39 | loadState: jest.fn(() => mockInkState)
40 | }));
41 |
42 | jest.mock('../../../src/utils/interfaces', () => ({
43 | interfaces: jest.fn(() => ({
44 | state: mockState,
45 | persistent: mockPersistent,
46 | loader: {
47 | init: mockInitLoader,
48 | loadInk: mockLoader,
49 | getAssetPath: mockGetAssetPath
50 | }
51 | }))
52 | }));
53 |
54 | jest.mock('../../../src/components/saves', () => {
55 | const saves = jest.requireActual('../../../src/components/saves');
56 | return {
57 | ...saves,
58 | load: jest.spyOn(saves, 'load'),
59 | save: jest.spyOn(saves, 'save'),
60 | existSave: jest.spyOn(saves, 'existSave')
61 | };
62 | });
63 |
64 |
65 | beforeEach(() => {
66 | mockState.reset();
67 | mockPersistent.reset();
68 | jest.clearAllMocks();
69 | mockGlobalTags = [];
70 | mockObserver = () => {};
71 | });
72 |
73 | describe('components/game', () => {
74 | // ============================================================
75 | // init
76 | // ============================================================
77 | describe('init', () => {
78 | test('default', async () => {
79 | const pathToInkFile = '/some/directory';
80 | const inkFile = 'game.ink.json';
81 | expect(emit).not.toHaveBeenCalled();
82 | await init(pathToInkFile, inkFile);
83 | expect(mockState.get().game).toEqual({
84 | $path: pathToInkFile,
85 | $file: inkFile,
86 | $gameUUID: hashCode(`${pathToInkFile}|${inkFile}`)
87 | });
88 | expect(mockInitLoader).toHaveBeenCalledWith(pathToInkFile);
89 | expect(emit).toHaveBeenCalledWith('game/init', { pathToInkFile, inkFile });
90 | });
91 |
92 | test('init - custom game UUID', async () => {
93 | const pathToInkFile = '/some/directory';
94 | const inkFile = 'game.ink.json';
95 | expect(emit).not.toHaveBeenCalled();
96 | await init(pathToInkFile, inkFile, 'customUUID');
97 | expect(mockState.get().game).toEqual({
98 | $path: pathToInkFile,
99 | $file: inkFile,
100 | $gameUUID: 'customUUID'
101 | });
102 | });
103 | });
104 |
105 | // ============================================================
106 | // loadInkFile
107 | // ============================================================
108 | test('loadInkFile', async () => {
109 | mockInkContent = '{"data":"ink content"}';
110 | const pathToInkFile = '/some/directory';
111 | const inkFile = 'game.ink.json';
112 | await init(pathToInkFile, inkFile);
113 | expect(mockLoader).not.toHaveBeenCalled();
114 | emit.mockClear();
115 | const inkContent = await loadInkFile();
116 | expect(inkContent).toEqual({ data: 'ink content' });
117 | expect(mockLoader).toHaveBeenCalledWith(inkFile);
118 | expect(emit).toHaveBeenCalledWith('game/loadInkFile', inkFile);
119 | });
120 |
121 | // ============================================================
122 | // clear
123 | // ============================================================
124 | test('clear', async () => {
125 | // setup
126 | expect(emit).not.toHaveBeenCalled();
127 | mockState.setKey('scenes', [{ text: 'aaa' }, { text: 'aaa' }]);
128 | mockState.setKey('vars', { aaa: 'bbb' });
129 | mockState.setKey('metadata', { ccc: 'ddd' });
130 | mockState.setKey('game', { ddd: 'eee' });
131 | expect(stopMusic).not.toHaveBeenCalled();
132 | expect(ink.resetStory).not.toHaveBeenCalled();
133 | // run
134 | clear();
135 | // check
136 | expect(stopMusic).toHaveBeenCalledTimes(1);
137 | expect(mockState.get().scenes).toEqual([]);
138 | expect(mockState.get().vars).toEqual({});
139 | expect(mockState.get().metadata).toEqual({ ccc: 'ddd' });
140 | expect(mockState.get().game).toEqual({ ddd: 'eee' });
141 | expect(ink.resetStory).toHaveBeenCalledTimes(1);
142 | expect(emit).toHaveBeenCalledWith('game/clear');
143 | });
144 |
145 | // ============================================================
146 | // reset
147 | // ============================================================
148 | test('reset', async () => {
149 | // setup
150 | expect(emit).not.toHaveBeenCalled();
151 | mockState.setKey('scenes', [{ text: 'aaa' }, { text: 'aaa' }]);
152 | mockState.setKey('vars', { aaa: 'bbb' });
153 | mockState.setKey('metadata', { ccc: 'ddd' });
154 | mockState.setKey('game', { ddd: 'eee' });
155 | expect(stopMusic).not.toHaveBeenCalled();
156 | // run
157 | reset();
158 | // check
159 | expect(stopMusic).toHaveBeenCalledTimes(1);
160 | expect(mockState.get().scenes).toEqual([]);
161 | expect(mockState.get().vars).toEqual({});
162 | expect(mockState.get().metadata).toEqual({});
163 | expect(mockState.get().game).toEqual({});
164 | expect(emit).toHaveBeenCalledWith('game/reset');
165 | });
166 |
167 | // ============================================================
168 | // start
169 | // ============================================================
170 | describe('start', () => {
171 | beforeEach(async () => {
172 | // reset
173 | await init(null, null, 'NONEXISTENT');
174 | await initInkStory();
175 | jest.clearAllMocks();
176 | });
177 |
178 | test('default', async () => {
179 | // set
180 | mockInkContent = { inkstory: 'inkContent' };
181 | mockGlobalTags = { globaltag1: true, globaltag2: true };
182 | mockState.setKey('scenes', [{ text: 'aaa' }, { text: 'aaa' }]);
183 | mockState.setKey('vars', { aaa: 'bbb' });
184 | mockState.setKey('metadata', { ccc: 'ddd' });
185 | const pathToInkFile = '/some/directory';
186 | const inkFile = 'game.ink.json';
187 | await init(pathToInkFile, inkFile);
188 | // run
189 | await start();
190 | // check
191 | expect(ink.initStory).toHaveBeenCalledTimes(1);
192 | expect(ink.initStory).toHaveBeenCalledWith(mockInkContent);
193 | expect(stopMusic).toHaveBeenCalledTimes(1);
194 | expect(mockState.get().scenes).toEqual([]);
195 | expect(mockState.get().vars).toEqual({});
196 | expect(mockState.get().metadata).toEqual(mockGlobalTags);
197 | expect(ink.observeVariable).not.toHaveBeenCalled();
198 | expect(existSave).not.toHaveBeenCalled();
199 | expect(load).not.toHaveBeenCalled();
200 | expect(emit).toHaveBeenCalledWith('game/start', { saveSlot: undefined });
201 | });
202 |
203 | test('restore single observer', async () => {
204 | // set
205 | mockGlobalTags = { observe: 'var1' };
206 | const pathToInkFile = '/some/directory';
207 | const inkFile = 'game.ink.json';
208 | await init(pathToInkFile, inkFile);
209 | expect(mockState.get().vars).toEqual({});
210 | // run
211 | await initInkStory();
212 | // expect observers to be registered, but values are not set yet
213 | expect(mockState.get().metadata).toEqual(mockGlobalTags);
214 | expect(ink.observeVariable).toHaveBeenCalledTimes(1);
215 | expect(ink.observeVariable).toHaveBeenCalledWith('var1', expect.any(Function));
216 | expect(mockState.get().vars).toEqual({});
217 | // run
218 | await start();
219 | // check
220 | expect(mockState.get().vars).toEqual({
221 | var1: 'var1-value'
222 | });
223 | expect(load).not.toHaveBeenCalled();
224 | // check observers
225 | emit.mockClear();
226 | mockObserver('var1', 50);
227 | expect(mockState.get().vars).toEqual({
228 | var1: 50
229 | });
230 | expect(emit).toHaveBeenCalledWith('ink/variableObserver', { name: 'var1', value: 50 });
231 | });
232 |
233 | test('restore observers', async () => {
234 | // set
235 | mockGlobalTags = { observe: ['var1', 'var2'] };
236 | const pathToInkFile = '/some/directory';
237 | const inkFile = 'game.ink.json';
238 | await init(pathToInkFile, inkFile);
239 | expect(mockState.get().vars).toEqual({});
240 | // run
241 | await initInkStory();
242 | // expect observers to be registered, but values are not set yet
243 | expect(mockState.get().metadata).toEqual(mockGlobalTags);
244 | expect(ink.observeVariable).toHaveBeenCalledTimes(2);
245 | expect(ink.observeVariable).toHaveBeenCalledWith('var1', expect.any(Function));
246 | expect(ink.observeVariable).toHaveBeenCalledWith('var2', expect.any(Function));
247 | expect(mockState.get().vars).toEqual({});
248 | // run
249 | await start();
250 | // check
251 | expect(mockState.get().vars).toEqual({
252 | var1: 'var1-value',
253 | var2: 'var2-value'
254 | });
255 | expect(load).not.toHaveBeenCalled();
256 | // check observers
257 | emit.mockClear();
258 | mockObserver('var1', 50);
259 | expect(mockState.get().vars).toEqual({
260 | var1: 50,
261 | var2: 'var2-value'
262 | });
263 | expect(emit).toHaveBeenCalledWith('ink/variableObserver', { name: 'var1', value: 50 });
264 | });
265 |
266 | test('handle persistent vars', async () => {
267 | // set
268 | mockGlobalTags = { persist: ['var1', 'var2'] };
269 | const pathToInkFile = '/some/directory';
270 | const inkFile = 'game.ink.json';
271 | await init(pathToInkFile, inkFile);
272 | const persistentStore = persistentPrefix('persist');
273 | expect(mockState.get().vars).toEqual({});
274 | // run
275 | await start();
276 | // expect observers to be registered, but no data are saved
277 | expect(mockState.get().metadata).toEqual(mockGlobalTags);
278 | expect(ink.observeVariable).toHaveBeenCalledTimes(2);
279 | expect(ink.observeVariable).toHaveBeenCalledWith('var1', expect.any(Function));
280 | expect(ink.observeVariable).toHaveBeenCalledWith('var2', expect.any(Function));
281 | expect(await mockPersistent.exists(persistentStore)).toEqual(false);
282 | await mockObserver('var1', 50);
283 | const state1 = await mockPersistent.get(persistentStore);
284 | expect(state1).toEqual({ var1: 50 });
285 | expect(ink.setVariable).not.toHaveBeenCalled();
286 | // reinit the game
287 | // run
288 | await start();
289 | expect(ink.setVariable).toHaveBeenCalledTimes(1);
290 | expect(ink.setVariable).toHaveBeenCalledWith('var1', 50);
291 | });
292 |
293 | test('load from nonexistent save', async () => {
294 | // set
295 | const pathToInkFile = '/some/directory';
296 | const inkFile = 'game.ink.json';
297 | await init(pathToInkFile, inkFile);
298 | // run
299 | await start('somesave');
300 | // check
301 | expect(existSave).toHaveBeenCalledTimes(1);
302 | expect(existSave).toHaveBeenCalledWith('somesave');
303 | expect(load).not.toHaveBeenCalled();
304 | });
305 |
306 | test('load from existing save', async () => {
307 | // set
308 | const pathToInkFile = '/some/directory';
309 | const inkFile = 'game.ink.json';
310 | const saveID = getSaveSlotKey({ type: SAVE_GAME, name: 'existingsave' });
311 | save({ type: SAVE_GAME, name: 'existingsave' });
312 | await init(pathToInkFile, inkFile);
313 | // run
314 | await start(saveID);
315 | // check
316 | expect(ink.initStory).toHaveBeenCalledTimes(1);
317 | expect(existSave).toHaveBeenCalledTimes(1);
318 | expect(existSave).toHaveBeenCalledWith(saveID);
319 | expect(load).toHaveBeenCalledTimes(1);
320 | expect(load).toHaveBeenCalledWith(saveID);
321 | });
322 |
323 | test('load from existing save - story is initialized', async () => {
324 | // set
325 | const pathToInkFile = '/some/directory';
326 | const inkFile = 'game.ink.json';
327 | const saveID = getSaveSlotKey({ type: SAVE_GAME, name: 'existingsave' });
328 | save({ type: SAVE_GAME, name: 'existingsave' });
329 | await init(pathToInkFile, inkFile);
330 | // run
331 | await start(saveID);
332 | // check
333 | expect(ink.initStory).toHaveBeenCalledTimes(1);
334 | expect(existSave).toHaveBeenCalledTimes(1);
335 | expect(existSave).toHaveBeenCalledWith(saveID);
336 | expect(load).toHaveBeenCalledTimes(1);
337 | expect(load).toHaveBeenCalledWith(saveID);
338 | });
339 |
340 | test('load ink file while starting', async () => {
341 | // set
342 | const pathToInkFile = '/some/directory';
343 | const inkFile = 'game.ink.json';
344 | await init(pathToInkFile, inkFile);
345 | mockInkContent = null; // reset ink content
346 | await loadInkFile();
347 | mockLoader.mockClear();
348 | // run
349 | await start();
350 | // check
351 | expect(mockLoader).toHaveBeenCalledWith(inkFile);
352 | expect(emit).toHaveBeenCalledWith('game/loadInkFile', inkFile);
353 | });
354 |
355 | test('load game state - restore music', async () => {
356 | // set
357 | const saveID = 'test_save_id';
358 | const pathToInkFile = '/some/directory';
359 | const inkFile = 'game.ink.json';
360 | mockPersistent.set(saveID, { game: { $currentMusic: 'music.mp3' } });
361 | await init(pathToInkFile, inkFile);
362 | // run
363 | expect(playMusic).not.toHaveBeenCalled();
364 | await start(saveID);
365 | // check
366 | expect(playMusic).toHaveBeenCalledWith('music.mp3');
367 | expect(emit).toHaveBeenCalledWith('game/start', { saveSlot: saveID });
368 | });
369 |
370 | test('load game state - restore music - false', async () => {
371 | // set
372 | const saveID = 'test_save_id';
373 | const pathToInkFile = '/some/directory';
374 | const inkFile = 'game.ink.json';
375 | mockPersistent.set(saveID, { game: { $currentMusic: false } });
376 | await init(pathToInkFile, inkFile);
377 | // run
378 | expect(playMusic).not.toHaveBeenCalled();
379 | await start(saveID);
380 | // check
381 | expect(playMusic).not.toHaveBeenCalled();
382 | expect(emit).toHaveBeenCalledWith('game/start', { saveSlot: saveID });
383 | });
384 | });
385 |
386 | describe('start of the same story', () => {
387 | beforeEach(async () => {
388 | const pathToInkFile = '/some/directory';
389 | const inkFile = 'game.ink.json';
390 | await init(pathToInkFile, inkFile);
391 | });
392 |
393 | test('start from beginning - same ink story', async () => {
394 | // set
395 | const pathToInkFile = '/some/directory';
396 | const inkFile = 'game.ink.json';
397 | await init(pathToInkFile, inkFile);
398 | // run
399 | await start();
400 | // check
401 | expect(ink.initStory).not.toHaveBeenCalled(); // this story is already initialized
402 | expect(ink.observeVariable).not.toHaveBeenCalled();
403 | expect(load).not.toHaveBeenCalled();
404 | });
405 |
406 | test('other story + ink content as string', async () => {
407 | // set
408 | mockInkContent = '{"inkstory":"inkContent"}';
409 | const pathToInkFile = '/some/directory2';
410 | const inkFile = 'game2.ink.json';
411 | await init(pathToInkFile, inkFile);
412 | await loadInkFile();
413 | // run
414 | await start();
415 | // check
416 | expect(ink.initStory).toHaveBeenCalledTimes(1);
417 | expect(ink.initStory).toHaveBeenCalledWith({ inkstory: 'inkContent' });
418 | expect(ink.observeVariable).not.toHaveBeenCalled();
419 | expect(load).not.toHaveBeenCalled();
420 | });
421 | });
422 | });
423 |
--------------------------------------------------------------------------------
/__tests__/components/game/index.test.js:
--------------------------------------------------------------------------------
1 | /* eslint-env jest */
2 | import mockPersistent from '../../../__mocks__/persistent';
3 | import mockState from '../../../__mocks__/state';
4 |
5 | import { emit } from '../../../src/utils/emitter';
6 |
7 | import ink from '../../../src/components/ink';
8 | import { playMusic, stopMusic, playSound, playSingleMusic, stopSound } from '../../../src/components/sound';
9 | import {
10 | load,
11 | save,
12 | existSave,
13 | removeSave,
14 | listSaves,
15 | SAVE_AUTOSAVE,
16 | SAVE_CHECKPOINT,
17 | SAVE_GAME,
18 | getSaveSlotKey
19 | } from '../../../src/components/saves';
20 |
21 | import game from '../../../src/components/game';
22 |
23 | let mockGlobalTags;
24 | let mockScene;
25 | let mockInkContent;
26 | const mockInitLoader = jest.fn(() => Promise.resolve());
27 | const mockLoader = jest.fn(() => Promise.resolve(mockInkContent));
28 | const mockInkState = { inkState: true };
29 | const mockGetAssetPath = jest.fn((file) => `${mockState.get().game.$path}/${file}`);
30 |
31 | const pause = (ms) => new Promise((res) => { setTimeout(res, ms); });
32 |
33 | jest.mock('../../../src/utils/emitter', () => ({
34 | emit: jest.fn()
35 | }));
36 |
37 | jest.mock('../../../src/components/ink', () => ({
38 | initStory: jest.fn(),
39 | getState: jest.fn(() => mockInkState),
40 | loadState: jest.fn(() => mockInkState),
41 | getGlobalTags: jest.fn(() => mockGlobalTags),
42 | getVariable: jest.fn((v) => `${v}-value`),
43 | setVariable: jest.fn(),
44 | getScene: jest.fn(() => JSON.parse(JSON.stringify(mockScene))),
45 | makeChoice: jest.fn(),
46 | resetStory: jest.fn()
47 | }));
48 |
49 | jest.mock('../../../src/components/sound', () => ({
50 | playMusic: jest.fn(),
51 | stopMusic: jest.fn(),
52 | playSound: jest.fn(),
53 | playSingleMusic: jest.fn(),
54 | stopSound: jest.fn()
55 | }));
56 |
57 | jest.mock('../../../src/components/saves', () => {
58 | const saves = jest.requireActual('../../../src/components/saves');
59 | return {
60 | ...saves,
61 | load: jest.spyOn(saves, 'load'),
62 | save: jest.spyOn(saves, 'save'),
63 | existSave: jest.spyOn(saves, 'existSave'),
64 | removeSave: jest.spyOn(saves, 'removeSave'),
65 | listSaves: jest.spyOn(saves, 'listSaves')
66 | };
67 | });
68 |
69 |
70 | jest.mock('../../../src/utils/interfaces', () => ({
71 | interfaces: jest.fn(() => ({
72 | state: mockState,
73 | persistent: mockPersistent,
74 | loader: {
75 | init: mockInitLoader,
76 | loadInk: mockLoader,
77 | getAssetPath: mockGetAssetPath
78 | }
79 | }))
80 | }));
81 |
82 |
83 | beforeEach(() => {
84 | mockState.reset();
85 | mockPersistent.reset();
86 | jest.clearAllMocks();
87 | mockScene = {};
88 | mockGlobalTags = [];
89 | });
90 |
91 | describe('components/game', () => {
92 | // ============================================================
93 | // canResume
94 | // ============================================================
95 | describe('canResume', () => {
96 | test('default', async () => {
97 | const expectedSaveslot = null; // no saves, can't resume
98 | const canResume = await game.canResume();
99 | expect(canResume).toBe(expectedSaveslot);
100 | expect(emit).toHaveBeenCalledWith('game/canResume', expectedSaveslot);
101 | });
102 |
103 | test('autosave', async () => {
104 | // set
105 | const expectedSaveslot = getSaveSlotKey({ type: SAVE_AUTOSAVE });
106 | await game.saveAutosave();
107 | // run
108 | const canResume = await game.canResume();
109 | // check
110 | expect(canResume).toBe(expectedSaveslot);
111 | expect(emit).toHaveBeenCalledWith('game/canResume', expectedSaveslot);
112 | });
113 |
114 | test('both autosave and checkpoints', async () => {
115 | // set
116 | await game.saveAutosave();
117 | await game.saveCheckpoint('point1');
118 | await pause(1);
119 | await game.saveCheckpoint('point2');
120 | await pause(1);
121 | await game.saveCheckpoint('point3');
122 | const expectedSaveslot = getSaveSlotKey({ type: SAVE_AUTOSAVE }); // autosave has higher precedence
123 | // run
124 | const canResume = await game.canResume();
125 | // check
126 | expect(canResume).toBe(expectedSaveslot);
127 | expect(emit).toHaveBeenCalledWith('game/canResume', expectedSaveslot);
128 | });
129 |
130 | test('checkpoints', async () => {
131 | // set
132 | await game.saveCheckpoint('point1');
133 | await pause(1);
134 | await game.saveCheckpoint('point2');
135 | await pause(1);
136 | await game.saveCheckpoint('point3');
137 | const expectedSaveslot = getSaveSlotKey({ type: SAVE_CHECKPOINT, name: 'point3' }); // latest checkpoint
138 | // run
139 | const canResume = await game.canResume();
140 | // check
141 | expect(canResume).toBe(expectedSaveslot);
142 | expect(emit).toHaveBeenCalledWith('game/canResume', expectedSaveslot);
143 | });
144 | });
145 |
146 | // ============================================================
147 | // resume
148 | // ============================================================
149 | describe('resume', () => {
150 | beforeEach(async () => {
151 | const pathToInkFile = '/some/directory';
152 | const inkFile = 'game.ink.json';
153 | await game.init(pathToInkFile, inkFile);
154 | });
155 |
156 | test('cannot resume - start again', async () => {
157 | expect(ink.resetStory).not.toHaveBeenCalled();
158 | await game.resume();
159 | expect(load).not.toHaveBeenCalled();
160 | expect(ink.resetStory).toHaveBeenCalledTimes(1);
161 | expect(emit).toHaveBeenCalledWith('game/resume', { saveSlot: null });
162 | expect(emit).toHaveBeenCalledWith('game/start', { saveSlot: null });
163 | });
164 |
165 | test('can resume - start from save', async () => {
166 | expect(ink.resetStory).not.toHaveBeenCalled();
167 | const saveID = getSaveSlotKey({ type: SAVE_AUTOSAVE });
168 | mockPersistent.set(saveID, { type: SAVE_AUTOSAVE, game: {} });
169 | await game.resume();
170 | expect(load).toHaveBeenCalledWith(saveID);
171 | expect(ink.resetStory).toHaveBeenCalledTimes(1);
172 | expect(emit).toHaveBeenCalledWith('game/resume', { saveSlot: saveID });
173 | expect(emit).toHaveBeenCalledWith('game/start', { saveSlot: saveID });
174 | });
175 | });
176 |
177 | // ============================================================
178 | // restart
179 | // ============================================================
180 | describe('restart', () => {
181 | beforeEach(async () => {
182 | const pathToInkFile = '/some/directory';
183 | const inkFile = 'game.ink.json';
184 | await game.init(pathToInkFile, inkFile);
185 | mockScene = { content: [{ text: 'aaa' }], text: ['aaaa'], tags: {}, choices: [] };
186 | });
187 |
188 | test('save slot is not set', async () => {
189 | expect(ink.resetStory).not.toHaveBeenCalled();
190 | expect(ink.getScene).not.toHaveBeenCalled();
191 | // run
192 | await game.saveAutosave();
193 | await game.restart();
194 | // check
195 | expect(load).not.toHaveBeenCalled();
196 | expect(ink.resetStory).toHaveBeenCalledTimes(1);
197 | expect(ink.getScene).not.toHaveBeenCalled(); // continueStory is not called
198 | expect(emit).toHaveBeenCalledWith('game/restart', { saveSlot: undefined });
199 | expect(emit).toHaveBeenCalledWith('game/start', { saveSlot: undefined });
200 | });
201 |
202 | test('save slot is set', async () => {
203 | expect(ink.resetStory).not.toHaveBeenCalled();
204 | expect(ink.getScene).not.toHaveBeenCalled();
205 | // run
206 | const saveID = getSaveSlotKey({ type: SAVE_AUTOSAVE });
207 | await game.saveAutosave();
208 | await game.restart(saveID);
209 | // check
210 | expect(load).toHaveBeenCalledWith(saveID);
211 | expect(ink.resetStory).toHaveBeenCalledTimes(1);
212 | expect(ink.getScene).not.toHaveBeenCalled(); // continueStory is not called
213 | expect(emit).toHaveBeenCalledWith('game/restart', { saveSlot: saveID });
214 | expect(emit).toHaveBeenCalledWith('game/start', { saveSlot: saveID });
215 | });
216 | });
217 |
218 | // ============================================================
219 | // restartAndContinue
220 | // ============================================================
221 | describe('restartAndContinue', () => {
222 | beforeEach(async () => {
223 | const pathToInkFile = '/some/directory';
224 | const inkFile = 'game.ink.json';
225 | await game.init(pathToInkFile, inkFile);
226 | mockScene = { content: [{ text: 'aaa' }], text: ['aaaa'], tags: {}, choices: [] };
227 | });
228 |
229 | test('save slot is not set', async () => {
230 | expect(ink.resetStory).not.toHaveBeenCalled();
231 | expect(ink.getScene).not.toHaveBeenCalled();
232 | // run
233 | await game.saveAutosave();
234 | await game.restartAndContinue();
235 | // check
236 | expect(load).not.toHaveBeenCalled();
237 | expect(ink.resetStory).toHaveBeenCalledTimes(1);
238 | expect(ink.getScene).toHaveBeenCalledTimes(1);
239 | expect(emit).toHaveBeenCalledWith('game/restart', { saveSlot: undefined });
240 | expect(emit).toHaveBeenCalledWith('game/start', { saveSlot: undefined });
241 | });
242 |
243 | test('save slot is set', async () => {
244 | expect(ink.resetStory).not.toHaveBeenCalled();
245 | expect(ink.getScene).not.toHaveBeenCalled();
246 | // run
247 | const saveID = getSaveSlotKey({ type: SAVE_AUTOSAVE });
248 | await game.saveAutosave();
249 | await game.restartAndContinue(saveID);
250 | // check
251 | expect(load).toHaveBeenCalledWith(saveID);
252 | expect(ink.resetStory).toHaveBeenCalledTimes(1);
253 | expect(ink.getScene).toHaveBeenCalledTimes(1);
254 | expect(emit).toHaveBeenCalledWith('game/restart', { saveSlot: saveID });
255 | expect(emit).toHaveBeenCalledWith('game/start', { saveSlot: saveID });
256 | });
257 | });
258 |
259 | // ============================================================
260 | // continueStory
261 | // ============================================================
262 | describe('continueStory', () => {
263 | const pathToInkFile = '/some/directory';
264 | const inkFile = 'game.ink.json';
265 | const processedMockScene = {
266 | content: [{ text: 'aaa', images: [], sounds: [], music: [] }],
267 | text: ['aaaa'],
268 | tags: {},
269 | choices: [],
270 | images: [],
271 | sounds: [],
272 | music: []
273 | };
274 | beforeEach(async () => {
275 | await game.init(pathToInkFile, inkFile);
276 | mockScene = { content: [{ text: 'aaa' }], text: ['aaaa'], tags: {}, choices: [] };
277 | });
278 |
279 | test('scene with empty content', () => {
280 | mockScene = { content: [] };
281 | game.continueStory();
282 | expect(mockState.get().scenes).toEqual([]);
283 | });
284 |
285 | test('scene basics', () => {
286 | mockScene = { content: [{ text: 'aaa' }], text: ['aaaa'], tags: {}, choices: [] };
287 | game.continueStory();
288 | expect(emit).toHaveBeenCalledWith('game/continueStory');
289 | expect(mockState.get().scenes).toEqual([processedMockScene]);
290 | game.continueStory();
291 | expect(mockState.get().scenes).toEqual([processedMockScene, processedMockScene]);
292 | expect(save).toHaveBeenCalledWith({ type: SAVE_AUTOSAVE }); // autosave by default
293 | });
294 |
295 | test('scene - single scene', () => {
296 | mockState.setKey('metadata', { single_scene: true });
297 | mockScene = { content: [{ text: 'aaa' }], text: ['aaaa'], tags: {}, choices: [] };
298 | game.continueStory();
299 | expect(emit).toHaveBeenCalledWith('game/continueStory');
300 | expect(mockState.get().scenes).toEqual([processedMockScene]);
301 | game.continueStory();
302 | expect(mockState.get().scenes).toEqual([processedMockScene]);
303 | });
304 |
305 | test('scene - autosave mode', () => {
306 | mockState.setKey('metadata', { autosave: true });
307 | mockScene = { content: [{ text: 'aaa' }], text: ['aaaa'], tags: {}, choices: [] };
308 | expect(save).not.toHaveBeenCalled();
309 | game.continueStory();
310 | expect(emit).toHaveBeenCalledWith('game/continueStory');
311 | expect(mockState.get().scenes).toEqual([processedMockScene]);
312 | expect(save).toHaveBeenCalledWith({ type: SAVE_AUTOSAVE });
313 | });
314 |
315 | test('scene - autosave disabled', () => {
316 | mockState.setKey('metadata', { autosave: false });
317 | mockScene = { content: [{ text: 'aaa' }], text: ['aaaa'], tags: {}, choices: [] };
318 | expect(save).not.toHaveBeenCalled();
319 | game.continueStory();
320 | expect(emit).toHaveBeenCalledWith('game/continueStory');
321 | expect(mockState.get().scenes).toEqual([processedMockScene]);
322 | expect(save).not.toHaveBeenCalled();
323 | });
324 |
325 | // ============================================================
326 | // continueStory - tags
327 | // ============================================================
328 | describe('tags', () => {
329 | let sampleScene;
330 | let processedSampleScene;
331 | beforeEach(() => {
332 | sampleScene = { content: [{ text: 'aaa' }], text: ['aaaa'], tags: {}, choices: [] };
333 | processedSampleScene = {
334 | ...sampleScene,
335 | content: [{ text: 'aaa', images: [], sounds: [], music: [] }],
336 | images: [],
337 | sounds: [],
338 | music: []
339 | };
340 | });
341 |
342 | test('CLEAR', () => {
343 | mockScene = { ...sampleScene };
344 | game.continueStory();
345 | expect(emit).toHaveBeenCalledWith('game/continueStory');
346 | expect(mockState.get().scenes).toEqual([processedSampleScene]);
347 | game.continueStory();
348 | expect(mockState.get().scenes).toEqual([processedSampleScene, processedSampleScene]);
349 | mockScene = { ...sampleScene, tags: { CLEAR: true } };
350 | game.continueStory();
351 | expect(mockState.get().scenes).toEqual([{ ...processedSampleScene, tags: { CLEAR: true } }]);
352 | });
353 |
354 | test('AUDIO - start', () => {
355 | const soundFile = 'sound.mp3';
356 | mockScene = { ...sampleScene, tags: { AUDIO: soundFile } };
357 | expect(playSound).not.toHaveBeenCalled();
358 | game.continueStory();
359 | expect(playSound).toHaveBeenCalledWith(soundFile);
360 | expect(playMusic).not.toHaveBeenCalled();
361 | expect(emit).toHaveBeenCalledWith('game/handletag', { AUDIO: soundFile });
362 | });
363 |
364 | test('AUDIO - stop', () => {
365 | const soundFile = false;
366 | mockScene = { ...sampleScene, tags: { AUDIO: soundFile } };
367 | expect(stopSound).not.toHaveBeenCalled();
368 | game.continueStory();
369 | expect(stopSound).toHaveBeenCalledTimes(1);
370 | expect(stopSound).toHaveBeenCalledWith();
371 | expect(stopMusic).not.toHaveBeenCalled();
372 | expect(emit).toHaveBeenCalledWith('game/handletag', { AUDIO: soundFile });
373 | });
374 |
375 | test('AUDIO - start multiple files', () => {
376 | const soundFile1 = 'sound.mp3';
377 | const soundFile2 = 'sound.mp3';
378 | mockScene = { ...sampleScene, tags: { AUDIO: [soundFile1, soundFile2] } };
379 | expect(playSound).not.toHaveBeenCalled();
380 | game.continueStory();
381 | expect(playSound).toHaveBeenCalledWith([soundFile1, soundFile2]);
382 | expect(playMusic).not.toHaveBeenCalled();
383 | expect(emit).toHaveBeenCalledWith('game/handletag', { AUDIO: [soundFile1, soundFile2] });
384 | });
385 |
386 | test('AUDIOLOOP - start', () => {
387 | const musicFile = 'music.mp3';
388 | mockScene = { ...sampleScene, tags: { AUDIOLOOP: musicFile } };
389 | expect(playSingleMusic).not.toHaveBeenCalled();
390 | game.continueStory();
391 | expect(playSingleMusic).toHaveBeenCalledWith(musicFile);
392 | expect(playSound).not.toHaveBeenCalled();
393 | expect(emit).toHaveBeenCalledWith('game/handletag', { AUDIOLOOP: musicFile });
394 | });
395 |
396 | test('AUDIOLOOP - stop', () => {
397 | const musicFile = false;
398 | mockScene = { ...sampleScene, tags: { AUDIOLOOP: musicFile } };
399 | expect(stopMusic).not.toHaveBeenCalled();
400 | game.continueStory();
401 | expect(stopMusic).toHaveBeenCalledTimes(1);
402 | expect(stopMusic).toHaveBeenCalledWith();
403 | expect(stopSound).not.toHaveBeenCalled();
404 | expect(emit).toHaveBeenCalledWith('game/handletag', { AUDIOLOOP: musicFile });
405 | });
406 |
407 | test('PLAY_SOUND', () => {
408 | const soundFile = 'sound.mp3';
409 | mockScene = { ...sampleScene, tags: { PLAY_SOUND: soundFile } };
410 | expect(playSound).not.toHaveBeenCalled();
411 | game.continueStory();
412 | expect(playSound).toHaveBeenCalledTimes(1);
413 | expect(playSound).toHaveBeenCalledWith(soundFile);
414 | expect(playMusic).not.toHaveBeenCalled();
415 | expect(emit).toHaveBeenCalledWith('game/handletag', { PLAY_SOUND: soundFile });
416 | });
417 |
418 |
419 | test('STOP_SOUND', () => {
420 | const soundFile = 'sound.mp3';
421 | mockScene = { ...sampleScene, tags: { STOP_SOUND: soundFile } };
422 | expect(stopSound).not.toHaveBeenCalled();
423 | game.continueStory();
424 | expect(stopSound).toHaveBeenCalledTimes(1);
425 | expect(stopSound).toHaveBeenCalledWith(soundFile);
426 | expect(stopMusic).not.toHaveBeenCalled();
427 | expect(emit).toHaveBeenCalledWith('game/handletag', { STOP_SOUND: soundFile });
428 | });
429 |
430 | test('STOP_SOUND - all', () => {
431 | mockScene = { ...sampleScene, tags: { STOP_SOUND: true } };
432 | expect(stopSound).not.toHaveBeenCalled();
433 | game.continueStory();
434 | expect(stopSound).toHaveBeenCalledTimes(1);
435 | expect(stopSound).toHaveBeenCalledWith();
436 | expect(stopMusic).not.toHaveBeenCalled();
437 | expect(emit).toHaveBeenCalledWith('game/handletag', { STOP_SOUND: true });
438 | });
439 |
440 | test('PLAY_MUSIC', () => {
441 | const musicFile = 'music.mp3';
442 | mockScene = { ...sampleScene, tags: { PLAY_MUSIC: musicFile } };
443 | expect(playMusic).not.toHaveBeenCalled();
444 | game.continueStory();
445 | expect(playMusic).toHaveBeenCalledTimes(1);
446 | expect(playMusic).toHaveBeenCalledWith(musicFile);
447 | expect(playSound).not.toHaveBeenCalled();
448 | expect(emit).toHaveBeenCalledWith('game/handletag', { PLAY_MUSIC: musicFile });
449 | });
450 |
451 | test('STOP_MUSIC', () => {
452 | const musicFile = 'music.mp3';
453 | mockScene = { ...sampleScene, tags: { STOP_MUSIC: musicFile } };
454 | expect(stopMusic).not.toHaveBeenCalled();
455 | game.continueStory();
456 | expect(stopMusic).toHaveBeenCalledTimes(1);
457 | expect(stopMusic).toHaveBeenCalledWith(musicFile);
458 | expect(stopSound).not.toHaveBeenCalled();
459 | expect(emit).toHaveBeenCalledWith('game/handletag', { STOP_MUSIC: musicFile });
460 | });
461 |
462 | test('STOP_MUSIC - all', () => {
463 | mockScene = { ...sampleScene, tags: { STOP_MUSIC: true } };
464 | expect(stopMusic).not.toHaveBeenCalled();
465 | game.continueStory();
466 | expect(stopMusic).toHaveBeenCalledTimes(1);
467 | expect(stopMusic).toHaveBeenCalledWith();
468 | expect(stopSound).not.toHaveBeenCalled();
469 | expect(emit).toHaveBeenCalledWith('game/handletag', { STOP_MUSIC: true });
470 | });
471 |
472 | test('CHECKPOINT - default', () => {
473 | mockScene = { ...sampleScene, tags: { CHECKPOINT: true } };
474 | expect(save).not.toHaveBeenCalled();
475 | game.continueStory();
476 | expect(save).toHaveBeenCalledWith({ type: SAVE_CHECKPOINT, name: true });
477 | expect(emit).toHaveBeenCalledWith('game/handletag', { CHECKPOINT: true });
478 | });
479 |
480 | test('CHECKPOINT - named', () => {
481 | mockScene = { ...sampleScene, tags: { CHECKPOINT: 'point1' } };
482 | expect(save).not.toHaveBeenCalled();
483 | game.continueStory();
484 | expect(save).toHaveBeenCalledWith({ type: SAVE_CHECKPOINT, name: 'point1' });
485 | expect(emit).toHaveBeenCalledWith('game/handletag', { CHECKPOINT: 'point1' });
486 | });
487 |
488 | test('SAVEGAME', () => {
489 | mockScene = { ...sampleScene, tags: { SAVEGAME: 'point2' } };
490 | expect(save).not.toHaveBeenCalled();
491 | game.continueStory();
492 | expect(save).toHaveBeenCalledWith({ type: SAVE_GAME, name: 'point2' });
493 | expect(emit).toHaveBeenCalledWith('game/handletag', { SAVEGAME: 'point2' });
494 | });
495 |
496 | test('RESTART', () => {
497 | mockScene = { ...sampleScene, tags: { RESTART: true } };
498 | expect(ink.resetStory).not.toHaveBeenCalled();
499 | // run
500 | game.continueStory();
501 | // check
502 | expect(ink.resetStory).toHaveBeenCalledTimes(1);
503 | expect(ink.getScene).toHaveBeenCalledTimes(1); // continueStory is not called
504 | expect(emit).toHaveBeenCalledWith('game/restart', { saveSlot: undefined });
505 | });
506 |
507 | test('RESTART_FROM_CHECKPOINT', () => {
508 | mockScene = { ...sampleScene, tags: { RESTART_FROM_CHECKPOINT: 'test_checkpoint' } };
509 | expect(ink.resetStory).not.toHaveBeenCalled();
510 | expect(load).not.toHaveBeenCalled();
511 | // run
512 | game.continueStory();
513 | // check
514 | expect(load).not.toHaveBeenCalled();
515 | expect(ink.resetStory).toHaveBeenCalledTimes(1);
516 | expect(ink.getScene).toHaveBeenCalledTimes(1); // continueStory is not called
517 | expect(emit).toHaveBeenCalledWith(
518 | 'game/restart',
519 | { saveSlot: getSaveSlotKey({ type: SAVE_CHECKPOINT, name: 'test_checkpoint' }) }
520 | );
521 | });
522 |
523 | test('custom scene processor', () => {
524 | mockScene = { ...sampleScene, tags: { CUSTOMTAG: 'test' } };
525 | const targetScene = { ...processedSampleScene, tags: { CUSTOMTAG: 'test' }, customtag: 'test' };
526 | const mockProcessor = jest.fn((s) => { s.customtag = s.tags.CUSTOMTAG; });
527 | game.defineSceneProcessor(mockProcessor);
528 | game.continueStory();
529 | expect(mockState.get().scenes).toEqual([targetScene]);
530 | expect(emit).not.toHaveBeenCalledWith('game/handletag', expect.any(Object));
531 | });
532 | });
533 | });
534 |
535 | // ============================================================
536 | // other methods
537 | // ============================================================
538 | test('getAssetPath', () => {
539 | const asset = 'aaa';
540 | expect(mockGetAssetPath).not.toHaveBeenCalled();
541 | game.getAssetPath(asset);
542 | expect(mockGetAssetPath).toHaveBeenCalledWith(asset);
543 | });
544 |
545 | test('makeChoice', () => {
546 | const id = 1;
547 | expect(ink.makeChoice).not.toHaveBeenCalled();
548 | game.makeChoice(id);
549 | expect(ink.makeChoice).toHaveBeenCalledWith(id);
550 | });
551 |
552 | test('load', async () => {
553 | const id = 'saveID';
554 | await game.saveGame(id);
555 | expect(load).not.toHaveBeenCalled();
556 | await game.load({ type: SAVE_GAME, name: id });
557 | expect(load).toHaveBeenCalledTimes(1);
558 | expect(load).toHaveBeenCalledWith({ type: SAVE_GAME, name: id });
559 | });
560 |
561 | test('saveGame', async () => {
562 | const id = 'saveID';
563 | expect(save).not.toHaveBeenCalled();
564 | await game.saveGame(id);
565 | expect(save).toHaveBeenCalledTimes(1);
566 | expect(save).toHaveBeenCalledWith({ type: SAVE_GAME, name: id });
567 | });
568 |
569 | test('saveCheckpoint', async () => {
570 | const id = 'saveID';
571 | expect(save).not.toHaveBeenCalled();
572 | await game.saveCheckpoint(id);
573 | expect(save).toHaveBeenCalledTimes(1);
574 | expect(save).toHaveBeenCalledWith({ type: SAVE_CHECKPOINT, name: id });
575 | });
576 |
577 | test('saveAutosave', async () => {
578 | expect(save).not.toHaveBeenCalled();
579 | await game.saveAutosave();
580 | expect(save).toHaveBeenCalledTimes(1);
581 | expect(save).toHaveBeenCalledWith({ type: SAVE_AUTOSAVE });
582 | });
583 |
584 |
585 | test('listSaves', async () => {
586 | expect(listSaves).not.toHaveBeenCalled();
587 | await game.listSaves();
588 | expect(listSaves).toHaveBeenCalledTimes(1);
589 | });
590 |
591 | test('removeSave', async () => {
592 | const id = 'saveSlot';
593 | expect(removeSave).not.toHaveBeenCalled();
594 | await game.removeSave(id);
595 | expect(removeSave).toHaveBeenCalledTimes(1);
596 | expect(removeSave).toHaveBeenCalledWith(id);
597 | });
598 |
599 | test('existSave', async () => {
600 | expect(existSave).not.toHaveBeenCalled();
601 | const x = await game.existSave('someSave');
602 | expect(existSave).toHaveBeenCalledTimes(1);
603 | expect(x).toBe(false);
604 | });
605 | });
606 |
--------------------------------------------------------------------------------
/__tests__/components/game/sessions.test.js:
--------------------------------------------------------------------------------
1 | /* eslint-env jest */
2 | import mockPersistent from '../../../__mocks__/persistent';
3 | import mockState from '../../../__mocks__/state';
4 |
5 | import { getSession, setSession, getSessions, removeSession } from '../../../src/components/game/sessions';
6 |
7 | import { emit } from '../../../src/utils/emitter';
8 |
9 | import {
10 | save,
11 | SAVE_GAME
12 | } from '../../../src/components/saves';
13 |
14 | jest.mock('../../../src/utils/emitter', () => ({
15 | emit: jest.fn()
16 | }));
17 |
18 | jest.mock('../../../src/components/ink', () => ({
19 | getState: jest.fn(() => ({ inkjson: 'content' }))
20 | }));
21 |
22 | jest.mock('../../../src/utils/interfaces', () => ({
23 | interfaces: jest.fn(() => ({
24 | state: mockState,
25 | persistent: mockPersistent
26 | }))
27 | }));
28 |
29 | beforeEach(() => {
30 | mockState.reset();
31 | mockPersistent.reset();
32 | jest.clearAllMocks();
33 | mockPersistent.init('myapp-test');
34 | mockState.setKey('game', { $gameUUID: 'test-game' });
35 | mockState.setKey('scenes', []);
36 | mockState.setKey('vars', {});
37 | });
38 |
39 |
40 |
41 | describe('components/sessions', () => {
42 | test('get/set session', () => {
43 | // default
44 | let session;
45 | session = getSession();
46 | expect(session).toBe('');
47 | // set
48 | setSession('session');
49 | session = getSession();
50 | expect(session).toBe('session');
51 | expect(emit).toHaveBeenCalledWith('game/setSession', 'session');
52 | emit.mockClear();
53 | // set number
54 | setSession(0);
55 | session = getSession();
56 | expect(session).toBe(0);
57 | expect(emit).toHaveBeenCalledWith('game/setSession', 0);
58 | emit.mockClear();
59 | // reset
60 | setSession(null);
61 | session = getSession();
62 | expect(session).toBe('');
63 | expect(emit).toHaveBeenCalledWith('game/setSession', '');
64 | });
65 |
66 | describe('getSessions', () => {
67 | test('empty', async () => {
68 | // run
69 | const sessions = await getSessions();
70 | // check
71 | expect(sessions).toEqual({});
72 | expect(emit).toHaveBeenCalledWith('game/getSessions', {});
73 | });
74 |
75 | test('single default session', async () => {
76 | // prepare
77 | save({ type: SAVE_GAME, name: 'save1' });
78 | save({ type: SAVE_GAME, name: 'save2' });
79 | const expectedSessions = { '': 2 };
80 | // run
81 | const sessions = await getSessions();
82 | // check
83 | expect(sessions).toEqual(expectedSessions);
84 | expect(emit).toHaveBeenCalledWith('game/getSessions', expectedSessions);
85 | });
86 |
87 | test('multiple sessions', async () => {
88 | // prepare
89 | save({ type: SAVE_GAME, name: 'save1' });
90 | save({ type: SAVE_GAME, name: 'save2' });
91 | setSession('session1');
92 | save({ type: SAVE_GAME, name: 'save1' });
93 | setSession('session2');
94 | save({ type: SAVE_GAME, name: 'save1' });
95 | save({ type: SAVE_GAME, name: 'save2' });
96 | save({ type: SAVE_GAME, name: 'save3' });
97 | const expectedSessions = {
98 | '': 2,
99 | session1: 1,
100 | session2: 3
101 | };
102 | // run
103 | const sessions = await getSessions();
104 | // check
105 | expect(sessions).toEqual(expectedSessions);
106 | expect(emit).toHaveBeenCalledWith('game/getSessions', expectedSessions);
107 | });
108 |
109 | test('exclude settings and persistent', async () => {
110 | // prepare
111 | save({ type: SAVE_GAME, name: 'save1' });
112 | save({ type: SAVE_GAME, name: 'save2' });
113 | mockPersistent.set('settings', { settings: true });
114 | mockPersistent.set('persist', { persist: true });
115 | const expectedSessions = { '': 2 };
116 | // run
117 | const sessions = await getSessions();
118 | // check
119 | expect(sessions).toEqual(expectedSessions);
120 | expect(emit).toHaveBeenCalledWith('game/getSessions', expectedSessions);
121 | });
122 | });
123 |
124 | describe('deleteSession', () => {
125 | test('empty', async () => {
126 | // run
127 | await removeSession();
128 | // check
129 | const sessions = await getSessions();
130 | expect(sessions).toEqual({});
131 | expect(emit).toHaveBeenCalledWith('game/deleteSession', '');
132 | });
133 |
134 | test('single default session', async () => {
135 | // prepare
136 | save({ type: SAVE_GAME, name: 'save1' });
137 | save({ type: SAVE_GAME, name: 'save2' });
138 | const expectedSessions = {};
139 | // run
140 | await removeSession();
141 | // check
142 | const sessions = await getSessions();
143 | expect(sessions).toEqual(expectedSessions);
144 | expect(emit).toHaveBeenCalledWith('game/deleteSession', '');
145 | });
146 |
147 | test('multiple sessions', async () => {
148 | // prepare
149 | save({ type: SAVE_GAME, name: 'save1' });
150 | save({ type: SAVE_GAME, name: 'save2' });
151 | setSession('session1');
152 | save({ type: SAVE_GAME, name: 'save1' });
153 | setSession('session2');
154 | save({ type: SAVE_GAME, name: 'save1' });
155 | save({ type: SAVE_GAME, name: 'save2' });
156 | save({ type: SAVE_GAME, name: 'save3' });
157 | const expectedSessions = {
158 | '': 2,
159 | session2: 3
160 | };
161 | // run
162 | await removeSession('session1');
163 | // check
164 | const sessions = await getSessions();
165 | expect(sessions).toEqual(expectedSessions);
166 | expect(emit).toHaveBeenCalledWith('game/deleteSession', 'session1');
167 | });
168 | });
169 | });
170 |
--------------------------------------------------------------------------------
/__tests__/components/ink.test.js:
--------------------------------------------------------------------------------
1 | /* eslint-env jest */
2 | import { emit } from '../../src/utils/emitter';
3 | import { setConfig } from '../../src/utils/config';
4 |
5 | import ink from '../../src/components/ink';
6 |
7 | jest.mock('../../src/utils/emitter', () => ({
8 | emit: jest.fn()
9 | }));
10 |
11 | jest.useFakeTimers();
12 |
13 | const mockInkStoryInstance = {
14 | content: '',
15 | sceneCounter: 1,
16 | globalTags: ['title: test story', 'autosave', 'single_scene', 'observe: var1'],
17 | state: {
18 | LoadJson: jest.fn(),
19 | toJson: jest.fn(() => ({ inkstate: 'jsonStructure' }))
20 | },
21 | onError: () => {},
22 | canContinue: true,
23 | currentChoices: [],
24 | Continue() {
25 | if (this.sceneCounter === 1) {
26 | this.currentText = '\n';
27 | } else {
28 | this.currentText = `Paragraph ${this.sceneCounter}.`;
29 | }
30 | this.currentTags = ['HELLO', 'WORLD'];
31 | this.sceneCounter += 1;
32 | if (this.sceneCounter > 3) {
33 | this.canContinue = false;
34 | this.currentChoices = [
35 | { text: 'Option 1' },
36 | { text: 'Option 2', tags: ['CHOICE', 'TEST: optional'] }
37 | ];
38 | }
39 | },
40 | ChooseChoiceIndex() {
41 | this.currentChoices = [];
42 | this.canContinue = true;
43 | },
44 | VisitCountAtPathString() {
45 | return 5;
46 | },
47 | EvaluateFunction(fn, args) {
48 | return `${fn} result: ${args[0] + args[1]}`;
49 | },
50 | variablesState: {
51 | var1: 'var1-value',
52 | var2: 'var2-value'
53 | },
54 | ObserveVariable() {
55 | return true;
56 | },
57 | ChoosePathString() {
58 | return true;
59 | },
60 | ResetState() {
61 | return true;
62 | }
63 | };
64 |
65 | const inkVars = Object.keys(mockInkStoryInstance.variablesState);
66 | const globalVars = new Map();
67 | inkVars.forEach((k) => globalVars.set(k, true));
68 | mockInkStoryInstance.variablesState._globalVariables = globalVars; /* eslint-disable-line no-underscore-dangle */
69 |
70 | const MockInkStory = jest.fn((content) => {
71 | mockInkStoryInstance.content = content;
72 | return mockInkStoryInstance;
73 | });
74 |
75 | const spyContinue = jest.spyOn(mockInkStoryInstance, 'Continue');
76 | const spyChooseChoiceIndex = jest.spyOn(mockInkStoryInstance, 'ChooseChoiceIndex');
77 | const spyVisitCountAtPathString = jest.spyOn(mockInkStoryInstance, 'VisitCountAtPathString');
78 | const spyEvaluateFunction = jest.spyOn(mockInkStoryInstance, 'EvaluateFunction');
79 | const spyObserveVariable = jest.spyOn(mockInkStoryInstance, 'ObserveVariable');
80 | const spyChoosePathString = jest.spyOn(mockInkStoryInstance, 'ChoosePathString');
81 | const spyResetState = jest.spyOn(mockInkStoryInstance, 'ResetState');
82 |
83 |
84 | beforeEach(() => {
85 | jest.clearAllMocks();
86 | mockInkStoryInstance.content = '';
87 | mockInkStoryInstance.currentChoices = [];
88 | mockInkStoryInstance.sceneCounter = 1;
89 | mockInkStoryInstance.canContinue = true;
90 | });
91 |
92 | describe('components/ink', () => {
93 | test('initStory', () => {
94 | const content = 'ink json';
95 | setConfig(MockInkStory, { applicationID: 'testAppID' });
96 | ink.initStory(content);
97 | const story = ink.story();
98 | expect(MockInkStory).toHaveBeenCalledWith(content);
99 | expect(story).toEqual(mockInkStoryInstance);
100 | expect(emit).toHaveBeenCalledWith('ink/initStory');
101 | });
102 |
103 | test('resetStory', () => {
104 | expect(spyResetState).toHaveBeenCalledTimes(0);
105 | ink.resetStory();
106 | expect(spyResetState).toHaveBeenCalledTimes(1);
107 | expect(emit).toHaveBeenCalledWith('ink/resetStory', true);
108 | });
109 |
110 | test('loadState', () => {
111 | expect(mockInkStoryInstance.state.LoadJson).not.toHaveBeenCalled();
112 | ink.loadState('saved-state');
113 | expect(mockInkStoryInstance.state.LoadJson).toHaveBeenCalledWith('saved-state');
114 | });
115 |
116 | test('getState', () => {
117 | expect(mockInkStoryInstance.state.toJson).not.toHaveBeenCalled();
118 | const state = ink.getState('saved-state');
119 | expect(state).toEqual({ inkstate: 'jsonStructure' });
120 | expect(mockInkStoryInstance.state.toJson).toHaveBeenCalledTimes(1);
121 | });
122 |
123 | test('makeChoice', () => {
124 | const choiceId = '1';
125 | expect(spyChooseChoiceIndex).not.toHaveBeenCalled();
126 | ink.makeChoice(choiceId);
127 | expect(spyChooseChoiceIndex).toHaveBeenCalledWith(choiceId);
128 | expect(emit).toHaveBeenCalledWith('ink/makeChoice', choiceId);
129 | });
130 |
131 | test('getVisitCount', () => {
132 | expect(spyVisitCountAtPathString).not.toHaveBeenCalled();
133 | const count = ink.getVisitCount('ref');
134 | expect(count).toEqual(5);
135 | expect(spyVisitCountAtPathString).toHaveBeenCalledWith('ref');
136 | expect(emit).toHaveBeenCalledWith('ink/getVisitCount', { ref: 'ref', visitCount: 5 });
137 | });
138 |
139 | test('evaluateFunction', () => {
140 | expect(spyEvaluateFunction).not.toHaveBeenCalled();
141 | const result = ink.evaluateFunction('testfunction', [3, 4]);
142 | expect(result).toEqual('testfunction result: 7');
143 | expect(spyEvaluateFunction).toHaveBeenCalledWith('testfunction', [3, 4], undefined);
144 | expect(emit).toHaveBeenCalledWith(
145 | 'ink/evaluateFunction',
146 | { function: 'testfunction', args: [3, 4], result: 'testfunction result: 7' }
147 | );
148 | });
149 |
150 | test('getGlobalTags', () => {
151 | const globaltags = ink.getGlobalTags();
152 | const expectedGlobalTags = {
153 | single_scene: true,
154 | autosave: true,
155 | observe: 'var1',
156 | title: 'test story'
157 | };
158 | expect(globaltags).toEqual(expectedGlobalTags);
159 | expect(emit).toHaveBeenCalledWith('ink/getGlobalTags', expectedGlobalTags);
160 | });
161 |
162 | test('getVariable', () => {
163 | const expectedValue = mockInkStoryInstance.variablesState.var1;
164 | const result = ink.getVariable('var1');
165 | expect(result).toEqual(expectedValue);
166 | expect(emit).toHaveBeenCalledWith('ink/getVariable', { name: 'var1', value: expectedValue });
167 | });
168 |
169 | test('getVariables', () => {
170 | const expectedValue = {
171 | var1: 'var1-value',
172 | var2: 'var2-value'
173 | };
174 | const result = ink.getVariables();
175 | expect(result).toEqual(expectedValue);
176 | expect(emit).toHaveBeenCalledWith('ink/getVariables', result);
177 | });
178 |
179 | test('setVariable', () => {
180 | ink.setVariable('varTest', 10);
181 | const expectedValue = mockInkStoryInstance.variablesState.varTest;
182 | expect(expectedValue).toEqual(10);
183 | expect(emit).toHaveBeenCalledWith('ink/setVariable', { name: 'varTest', value: 10 });
184 | });
185 |
186 | test('observeVariable', () => {
187 | expect(spyObserveVariable).not.toHaveBeenCalled();
188 | const handler = () => 'var-handler';
189 | ink.observeVariable('var1', handler);
190 | expect(spyObserveVariable).toHaveBeenCalledWith('var1', handler);
191 | });
192 |
193 | test('goTo', () => {
194 | expect(spyChoosePathString).not.toHaveBeenCalled();
195 | const knot = 'knotAddress';
196 | ink.goTo(knot);
197 | expect(spyChoosePathString).toHaveBeenCalledWith(knot);
198 | expect(emit).toHaveBeenCalledWith('ink/goTo', knot);
199 | });
200 |
201 | test('onError', () => {
202 | const errorEvent = { error: 'Internal error' };
203 | let checkErrorEvent = null;
204 | ink.onError((error) => { checkErrorEvent = error; });
205 | // check
206 | expect(checkErrorEvent).not.toEqual(errorEvent);
207 | ink.story().onError(errorEvent);
208 | expect(checkErrorEvent).toEqual(errorEvent);
209 | expect(emit).toHaveBeenCalledWith('ink/onError', errorEvent);
210 | });
211 |
212 | test('getScene', () => {
213 | expect(spyContinue).not.toHaveBeenCalled();
214 | const expectedScene = {
215 | content: [
216 | {
217 | text: '\n',
218 | tags: { HELLO: true, WORLD: true }
219 | },
220 | {
221 | text: 'Paragraph 2.',
222 | tags: { HELLO: true, WORLD: true }
223 | }
224 | ],
225 | text: [
226 | '\n',
227 | 'Paragraph 2.'
228 | ],
229 | tags: {
230 | HELLO: true,
231 | WORLD: true
232 | },
233 | canContinue: true, // in "continue" mode, adds canContinue flag
234 | choices: [],
235 | uuid: jest.now() // equivalent to Date.now()
236 | };
237 | const scene = ink.getScene();
238 | expect(spyContinue).toHaveBeenCalledTimes(2); // first paragraph has "\n"
239 | expect(scene).toEqual(expectedScene);
240 | expect(emit).toHaveBeenCalledWith('ink/getScene', scene);
241 | });
242 |
243 | test('getScene - maximal continue', () => {
244 | expect(spyContinue).not.toHaveBeenCalled();
245 | const expectedScene = {
246 | content: [
247 | {
248 | text: '\n',
249 | tags: { HELLO: true, WORLD: true }
250 | },
251 | {
252 | text: 'Paragraph 2.',
253 | tags: { HELLO: true, WORLD: true }
254 | },
255 | {
256 | text: 'Paragraph 3.',
257 | tags: { HELLO: true, WORLD: true }
258 | }
259 | ],
260 | text: [
261 | '\n',
262 | 'Paragraph 2.',
263 | 'Paragraph 3.'
264 | ],
265 | tags: {
266 | HELLO: true,
267 | WORLD: true
268 | },
269 | choices: [
270 | { id: 0, choice: 'Option 1', tags: {} },
271 | { id: 1, choice: 'Option 2', tags: { CHOICE: true, TEST: 'optional' } }
272 | ],
273 | uuid: jest.now() // equivalent to Date.now()
274 | };
275 | const scene = ink.getScene(true);
276 | expect(spyContinue).toHaveBeenCalledTimes(3);
277 | expect(scene).toEqual(expectedScene);
278 | expect(emit).toHaveBeenCalledWith('ink/getScene', scene);
279 | });
280 | });
281 |
--------------------------------------------------------------------------------
/__tests__/components/saves.test.js:
--------------------------------------------------------------------------------
1 | /* eslint-env jest */
2 | import mockPersistent from '../../__mocks__/persistent';
3 | import mockState from '../../__mocks__/state';
4 |
5 | import { emit } from '../../src/utils/emitter';
6 | import { setSession } from '../../src/components/game/sessions';
7 | import ink from '../../src/components/ink';
8 | import {
9 | getSaveSlotKey,
10 | load,
11 | save,
12 | existSave,
13 | removeSave,
14 | listSaves,
15 | SAVE_GAME,
16 | SAVE_AUTOSAVE,
17 | SAVE_CHECKPOINT
18 | } from '../../src/components/saves';
19 |
20 | jest.mock('../../src/utils/emitter', () => ({
21 | emit: jest.fn()
22 | }));
23 |
24 | jest.mock('../../src/components/ink', () => ({
25 | loadState: jest.fn(),
26 | getState: jest.fn(() => ({ inkjson: 'content' })),
27 | getVariable: jest.fn((v) => `${v}-value`)
28 | }));
29 |
30 | jest.mock('../../src/utils/interfaces', () => ({
31 | interfaces: jest.fn(() => ({
32 | state: mockState,
33 | persistent: mockPersistent
34 | }))
35 | }));
36 |
37 | jest
38 | .useFakeTimers()
39 | .setSystemTime(new Date('2023-08-09'));
40 |
41 | beforeEach(() => {
42 | mockState.reset();
43 | mockPersistent.reset();
44 | jest.clearAllMocks();
45 | mockPersistent.init('myapp-test');
46 | mockState.setKey('game', { $gameUUID: 'test-game' });
47 | mockState.setKey('scenes', []);
48 | mockState.setKey('vars', {});
49 | });
50 |
51 | describe('components/saves', () => {
52 | describe('getSaveSlotKey', () => {
53 | test('default', () => {
54 | const slotName = getSaveSlotKey({ type: 'autosave' });
55 | expect(slotName).toBe(`test-game//save/${SAVE_AUTOSAVE}/`);
56 | });
57 | test('default - named', () => {
58 | const slotName = getSaveSlotKey({ type: 'checkpoint', name: 'stage1' });
59 | expect(slotName).toBe(`test-game//save/${SAVE_CHECKPOINT}/stage1`);
60 | });
61 | test('default - numbered', () => {
62 | const slotName = getSaveSlotKey({ type: 'checkpoint', name: 1 });
63 | expect(slotName).toBe(`test-game//save/${SAVE_CHECKPOINT}/1`);
64 | });
65 | test('default - untyped', () => {
66 | const slotName = getSaveSlotKey({ type: true, name: 'check' });
67 | expect(slotName).toBe(`test-game//save/${SAVE_GAME}/check`);
68 | });
69 | test('default - unnamed', () => {
70 | const slotName = getSaveSlotKey({ type: 'checkpoint', name: true });
71 | expect(slotName).toBe(`test-game//save/${SAVE_CHECKPOINT}/`);
72 | });
73 | test('session', () => {
74 | mockState.setSubkey('game', '$sessionID', 's1');
75 | const slotName = getSaveSlotKey({ type: 'autosave' });
76 | expect(slotName).toBe(`test-game/s1/save/${SAVE_AUTOSAVE}/`);
77 | });
78 | test('session - named', () => {
79 | mockState.setSubkey('game', '$sessionID', 's1');
80 | const slotName = getSaveSlotKey({ type: 'checkpoint', name: 'stage1' });
81 | expect(slotName).toBe(`test-game/s1/save/${SAVE_CHECKPOINT}/stage1`);
82 | });
83 | });
84 |
85 | describe('save', () => {
86 | test('save game state', async () => {
87 | const mockScenes = ['scene1', 'scene2'];
88 | mockState.setKey('scenes', mockScenes);
89 | const type = 'checkpoint';
90 | const name = 'stage2';
91 | // run
92 | await save({ type, name });
93 | // check
94 | expect(ink.getState).toHaveBeenCalledTimes(1);
95 | const saveID = getSaveSlotKey({ type, name });
96 | const saved = await mockPersistent.get(saveID);
97 | expect(saved).toEqual({
98 | name,
99 | type,
100 | date: 1691539200000,
101 | game: mockState.get().game,
102 | scenes: mockScenes,
103 | state: { inkjson: 'content' }
104 | });
105 | expect(emit).toHaveBeenCalledWith('game/save', saveID);
106 | });
107 | });
108 |
109 |
110 | describe('load', () => {
111 | test('load game state', async () => {
112 | const mockScenes = ['scene1', 'scene2'];
113 | mockState.setKey('scenes', mockScenes);
114 | const type = 'checkpoint';
115 | const name = 'stage2';
116 | // run
117 | await save({ type, name });
118 | emit.mockClear();
119 | // load
120 | const saveID = getSaveSlotKey({ type, name });
121 | await load(saveID);
122 | expect(ink.loadState).toHaveBeenCalledWith({ inkjson: 'content' });
123 | expect(mockState.get().scenes).toEqual(mockScenes);
124 | expect(mockState.get().game).toEqual({ $gameUUID: 'test-game' });
125 | expect(mockState.get().vars).toEqual({});
126 | expect(emit).toHaveBeenCalledWith('game/load', saveID);
127 | });
128 | });
129 |
130 | describe('exist', () => {
131 | test('game state exists', async () => {
132 | const type = 'checkpoint';
133 | const name = 'stage2';
134 | const saveID = getSaveSlotKey({ type, name });
135 | // check
136 | let saveExists = await existSave(saveID);
137 | expect(saveExists).toBe(false);
138 | await save({ name, type });
139 | saveExists = await existSave(saveID);
140 | expect(saveExists).toBe(true);
141 | await removeSave(saveID);
142 | saveExists = await existSave(saveID);
143 | expect(saveExists).toBe(false);
144 | });
145 | });
146 |
147 | describe('listSaves', () => {
148 | test('get saves for specific game', async () => {
149 | mockState.setSubkey('game', '$gameUUID', 'UUID1');
150 | await save({ type: 'game', name: 'game1_save1' });
151 | await save({ type: 'checkpoint', name: 'game1_save2' });
152 | await save({ type: 'autosave' });
153 | let savesList = await listSaves();
154 | expect(savesList).toEqual([{
155 | id: getSaveSlotKey({ type: 'game', name: 'game1_save1' }),
156 | name: 'game1_save1',
157 | type: 'game',
158 | game: { $gameUUID: 'UUID1' },
159 | date: 1691539200000
160 | }, {
161 | id: getSaveSlotKey({ type: 'checkpoint', name: 'game1_save2' }),
162 | name: 'game1_save2',
163 | type: 'checkpoint',
164 | game: { $gameUUID: 'UUID1' },
165 | date: 1691539200000
166 | }, {
167 | id: getSaveSlotKey({ type: 'autosave' }),
168 | type: 'autosave',
169 | game: { $gameUUID: 'UUID1' },
170 | date: 1691539200000
171 | }]);
172 | // switch to another game
173 | mockState.setSubkey('game', '$gameUUID', 'UUID2');
174 | savesList = await listSaves();
175 | expect(savesList).toEqual([]);
176 | expect(emit).toHaveBeenCalledWith('game/listSaves', []);
177 | // save something under new UUID
178 | await save({ type: 'checkpoint', name: 'game2_checkpoint' });
179 | savesList = await listSaves();
180 | expect(savesList).toEqual([{
181 | id: getSaveSlotKey({ type: 'checkpoint', name: 'game2_checkpoint' }),
182 | name: 'game2_checkpoint',
183 | type: 'checkpoint',
184 | game: { $gameUUID: 'UUID2' },
185 | date: 1691539200000
186 | }]);
187 | expect(emit).toHaveBeenCalledWith('game/listSaves', savesList);
188 | // back to original game
189 | mockState.setSubkey('game', '$gameUUID', 'UUID1');
190 | savesList = await listSaves();
191 | expect(savesList).toEqual([{
192 | id: getSaveSlotKey({ type: 'game', name: 'game1_save1' }),
193 | name: 'game1_save1',
194 | type: 'game',
195 | game: { $gameUUID: 'UUID1' },
196 | date: 1691539200000
197 | }, {
198 | id: getSaveSlotKey({ type: 'checkpoint', name: 'game1_save2' }),
199 | name: 'game1_save2',
200 | type: 'checkpoint',
201 | game: { $gameUUID: 'UUID1' },
202 | date: 1691539200000
203 | }, {
204 | id: getSaveSlotKey({ type: 'autosave' }),
205 | type: 'autosave',
206 | game: { $gameUUID: 'UUID1' },
207 | date: 1691539200000
208 | }]);
209 | expect(emit).toHaveBeenCalledWith('game/listSaves', savesList);
210 | });
211 |
212 | test('list saves for specific session', async () => {
213 | mockState.setSubkey('game', '$gameUUID', 'UUID1');
214 | await save({ type: 'game', name: 'game1_save1' });
215 | await save({ type: 'checkpoint', name: 'game1_save2' });
216 | setSession('session1');
217 | await save({ type: 'game', name: 'game1_session1_save1' });
218 | await save({ type: 'checkpoint', name: 'game1_session1_save2' });
219 | setSession('session2');
220 | await save({ type: 'game', name: 'game1_session2_save1' });
221 | await save({ type: 'checkpoint', name: 'game1_session2_save2' });
222 | let savesList;
223 | // check saves for default session
224 | setSession('');
225 | savesList = await listSaves();
226 | expect(savesList).toEqual([{
227 | id: getSaveSlotKey({ type: 'game', name: 'game1_save1' }),
228 | name: 'game1_save1',
229 | type: 'game',
230 | game: { $gameUUID: 'UUID1' },
231 | date: 1691539200000
232 | }, {
233 | id: getSaveSlotKey({ type: 'checkpoint', name: 'game1_save2' }),
234 | name: 'game1_save2',
235 | type: 'checkpoint',
236 | game: { $gameUUID: 'UUID1' },
237 | date: 1691539200000
238 | }]);
239 | // check saves for session1
240 | setSession('session1');
241 | savesList = await listSaves();
242 | expect(savesList).toEqual([{
243 | id: getSaveSlotKey({ type: 'game', name: 'game1_session1_save1' }),
244 | name: 'game1_session1_save1',
245 | type: 'game',
246 | game: { $gameUUID: 'UUID1', $sessionID: 'session1' },
247 | date: 1691539200000
248 | }, {
249 | id: getSaveSlotKey({ type: 'checkpoint', name: 'game1_session1_save2' }),
250 | name: 'game1_session1_save2',
251 | type: 'checkpoint',
252 | game: { $gameUUID: 'UUID1', $sessionID: 'session1' },
253 | date: 1691539200000
254 | }]);
255 | });
256 | });
257 | });
258 |
--------------------------------------------------------------------------------
/__tests__/components/settings.test.js:
--------------------------------------------------------------------------------
1 | /* eslint-env jest */
2 | import mockPersistent from '../../__mocks__/persistent';
3 | import mockState from '../../__mocks__/state';
4 | import { emit } from '../../src/utils/emitter';
5 |
6 | import settings from '../../src/components/settings';
7 | import { setConfig } from '../../src/utils/config';
8 |
9 | const mockSound = {
10 | mute: jest.fn(),
11 | setVolume: jest.fn()
12 | };
13 |
14 | jest.mock('../../src/utils/emitter', () => ({
15 | emit: jest.fn()
16 | }));
17 |
18 | jest.mock('../../src/utils/interfaces', () => ({
19 | interfaces: jest.fn(() => ({
20 | state: mockState,
21 | persistent: mockPersistent,
22 | sound: mockSound
23 | }))
24 | }));
25 |
26 | const defaultConfig = {
27 | applicationID: '!CHANGE_THIS',
28 | settings: {
29 | volume: 0,
30 | mute: true
31 | }
32 | };
33 |
34 | const Story = () => ({ inkStoryConstructor: true });
35 |
36 | beforeEach(() => {
37 | mockState.reset();
38 | mockPersistent.reset();
39 | jest.clearAllMocks();
40 | setConfig(Story, defaultConfig);
41 | });
42 |
43 |
44 | describe('components/settings', () => {
45 | describe('load', () => {
46 | test('default settings', async () => {
47 | await settings.load();
48 | expect(mockState.get().settings).toEqual({
49 | mute: true,
50 | volume: 0
51 | });
52 | expect(mockSound.mute).toHaveBeenCalledWith(true);
53 | expect(mockSound.setVolume).toHaveBeenCalledWith(0);
54 | expect(emit).toHaveBeenCalledWith('settings/load', {
55 | mute: true,
56 | volume: 0
57 | });
58 | });
59 |
60 | test('settings from config', async () => {
61 | setConfig(Story, {
62 | applicationID: 'test-app',
63 | settings: {
64 | mute: false,
65 | volume: 5,
66 | fullscreen: false,
67 | fontsize: 10
68 | }
69 | });
70 | await settings.load();
71 | expect(mockState.get().settings).toEqual({
72 | mute: false,
73 | volume: 5,
74 | fullscreen: false,
75 | fontsize: 10
76 | });
77 | expect(mockSound.mute).toHaveBeenCalledWith(false);
78 | expect(mockSound.setVolume).toHaveBeenCalledWith(5);
79 | });
80 |
81 | test('settings from persistent - with new defaults', async () => {
82 | const defaultConfig2 = {
83 | applicationID: '!CHANGE_THIS',
84 | settings: {
85 | volume: 0,
86 | mute: true,
87 | fontFamily: 'System'
88 | }
89 | };
90 | setConfig(Story, defaultConfig2);
91 | const savedSettings = {
92 | mute: false,
93 | volume: 10,
94 | fullscreen: true,
95 | fontsize: 20
96 | };
97 | const expectedSettings = {
98 | ...savedSettings,
99 | fontFamily: 'System'
100 | };
101 | await mockPersistent.set('settings', savedSettings);
102 | await settings.load();
103 | expect(mockState.get().settings).toEqual(expectedSettings);
104 | expect(mockSound.mute).toHaveBeenCalledWith(false);
105 | expect(mockSound.setVolume).toHaveBeenCalledWith(10);
106 | expect(emit).toHaveBeenCalledWith('settings/load', expectedSettings);
107 | });
108 | });
109 |
110 | test('settings from persistent', async () => {
111 | const savedSettings = {
112 | mute: false,
113 | volume: 10,
114 | fullscreen: true,
115 | fontsize: 20
116 | };
117 | await mockPersistent.set('settings', savedSettings);
118 | await settings.load();
119 | expect(mockState.get().settings).toEqual(savedSettings);
120 | expect(mockSound.mute).toHaveBeenCalledWith(false);
121 | expect(mockSound.setVolume).toHaveBeenCalledWith(10);
122 | expect(emit).toHaveBeenCalledWith('settings/load', savedSettings);
123 | });
124 |
125 | describe('save', () => {
126 | test('save settings', async () => {
127 | const appSettings = {
128 | mute: false,
129 | volume: 5,
130 | fullscreen: false,
131 | fontsize: 15
132 | };
133 | mockState.setKey('settings', appSettings);
134 | await settings.save();
135 | const savedSettings = await mockPersistent.get('settings');
136 | expect(savedSettings).toEqual(appSettings);
137 | expect(emit).toHaveBeenCalledWith('settings/save', savedSettings);
138 | });
139 | });
140 |
141 | describe('get', () => {
142 | test('get setting', () => {
143 | const appSettings = {
144 | mute: false,
145 | volume: 5,
146 | fullscreen: false,
147 | fontsize: 15
148 | };
149 | mockState.setKey('settings', appSettings);
150 | const fontsize = settings.get('fontsize');
151 | expect(fontsize).toBe(appSettings.fontsize);
152 | const mute = settings.get('mute');
153 | expect(mute).toBe(appSettings.mute);
154 | expect(emit).toHaveBeenCalledWith('settings/get', { name: 'mute', value: appSettings.mute });
155 | });
156 | });
157 |
158 | describe('toggle', () => {
159 | test('toggle setting', () => {
160 | const appSettings = {
161 | mute: false,
162 | volume: 5,
163 | fullscreen: false,
164 | fontsize: 15
165 | };
166 | mockState.setKey('settings', appSettings);
167 | expect(settings.get('mute')).toBe(false);
168 | settings.toggle('mute');
169 | expect(settings.get('mute')).toBe(true);
170 | settings.toggle('mute');
171 | expect(settings.get('mute')).toBe(false);
172 | });
173 | });
174 |
175 | describe('set', () => {
176 | test('set value - mute (default handler)', () => {
177 | expect(mockSound.mute).not.toHaveBeenCalled();
178 | settings.set('mute', true);
179 | expect(emit).toHaveBeenCalledWith('settings/set', { name: 'mute', value: true });
180 | expect(mockSound.mute).toHaveBeenCalledWith(true);
181 | expect(settings.get('mute')).toBe(true);
182 | // toggle
183 | emit.mockClear();
184 | mockSound.mute.mockClear();
185 | settings.toggle('mute');
186 | expect(emit).toHaveBeenCalledWith('settings/set', { name: 'mute', value: false });
187 | expect(mockSound.mute).toHaveBeenCalledWith(false);
188 | expect(settings.get('mute')).toBe(false);
189 | });
190 |
191 | test('set value - setVolume (default handler)', () => {
192 | expect(mockSound.setVolume).not.toHaveBeenCalled();
193 | settings.set('volume', 50);
194 | expect(emit).toHaveBeenCalledWith('settings/set', { name: 'volume', value: 50 });
195 | expect(mockSound.setVolume).toHaveBeenCalledWith(50);
196 | expect(settings.get('volume')).toBe(50);
197 | // change
198 | emit.mockClear();
199 | mockSound.setVolume.mockClear();
200 | settings.set('volume', 90);
201 | expect(emit).toHaveBeenCalledWith('settings/set', { name: 'volume', value: 90 });
202 | expect(mockSound.setVolume).toHaveBeenCalledWith(90);
203 | expect(settings.get('volume')).toBe(90);
204 | });
205 | });
206 |
207 | describe('handlers', () => {
208 | test('custom handler', () => {
209 | const customHandler = jest.fn();
210 | settings.set('fontsize', 10);
211 | expect(customHandler).not.toHaveBeenCalled();
212 | settings.defineHandler('fontsize', customHandler);
213 | // set value
214 | settings.set('fontsize', 10);
215 | expect(customHandler).toHaveBeenCalledWith(10, 10); // old and new value
216 | expect(settings.get('fontsize')).toBe(10);
217 | // change
218 | customHandler.mockClear();
219 | settings.set('fontsize', 30);
220 | expect(customHandler).toHaveBeenCalledWith(10, 30); // old and new value
221 | expect(settings.get('fontsize')).toBe(30);
222 | });
223 | });
224 | });
225 |
--------------------------------------------------------------------------------
/__tests__/components/sound.test.js:
--------------------------------------------------------------------------------
1 | /* eslint-env jest */
2 | import { playSound, stopSound, playMusic, playSingleMusic, stopMusic } from '../../src/components/sound';
3 | import mockState from '../../__mocks__/state';
4 |
5 | const mockPlaySound = jest.fn();
6 | const mockStopSound = jest.fn();
7 | const mockPlayMusic = jest.fn();
8 | const mockStopMusic = jest.fn();
9 |
10 | jest.mock('../../src/utils/interfaces', () => ({
11 | interfaces: jest.fn(() => ({
12 | state: mockState,
13 | sound: {
14 | playSound: mockPlaySound,
15 | stopSound: mockStopSound,
16 | playMusic: mockPlayMusic,
17 | stopMusic: mockStopMusic
18 | },
19 | loader: {
20 | getAssetPath: (file) => `/assets/${file}`
21 | }
22 | }))
23 | }));
24 |
25 | beforeEach(() => {
26 | mockState.reset();
27 | jest.clearAllMocks();
28 | });
29 |
30 |
31 | describe('components/sound', () => {
32 | test('playSound', () => {
33 | expect(mockPlaySound).toHaveBeenCalledTimes(0);
34 | playSound('sound/test.mp3');
35 | expect(mockPlaySound).toHaveBeenCalledTimes(1);
36 | expect(mockPlaySound).toHaveBeenCalledWith('/assets/sound/test.mp3');
37 | });
38 |
39 | test('playSound - multiple', () => {
40 | expect(mockPlaySound).toHaveBeenCalledTimes(0);
41 | playSound(['sound/file1.mp3', 'sound/file2.mp3']);
42 | expect(mockPlaySound).toHaveBeenCalledTimes(2);
43 | expect(mockPlaySound).toHaveBeenCalledWith('/assets/sound/file1.mp3');
44 | expect(mockPlaySound).toHaveBeenCalledWith('/assets/sound/file2.mp3');
45 | });
46 |
47 | test('stopSound - specific file', () => {
48 | expect(mockStopSound).toHaveBeenCalledTimes(0);
49 | stopSound('sound/test.mp3');
50 | expect(mockStopSound).toHaveBeenCalledTimes(1);
51 | expect(mockStopSound).toHaveBeenCalledWith('/assets/sound/test.mp3');
52 | });
53 |
54 | test('stopSound - multiple files', () => {
55 | expect(mockStopSound).toHaveBeenCalledTimes(0);
56 | stopSound(['sound/file1.mp3', 'sound/file2.mp3']);
57 | expect(mockStopSound).toHaveBeenCalledTimes(2);
58 | expect(mockStopSound).toHaveBeenCalledWith('/assets/sound/file1.mp3');
59 | expect(mockStopSound).toHaveBeenCalledWith('/assets/sound/file2.mp3');
60 | });
61 |
62 | test('stopSound - all sounds', () => {
63 | expect(mockStopSound).toHaveBeenCalledTimes(0);
64 | stopSound();
65 | expect(mockStopSound).toHaveBeenCalledTimes(1);
66 | expect(mockStopSound).toHaveBeenCalledWith(null);
67 | });
68 |
69 | test('playMusic', () => {
70 | expect(mockPlayMusic).toHaveBeenCalledTimes(0);
71 | expect(mockStopMusic).toHaveBeenCalledTimes(0);
72 | playMusic('sound/test.mp3');
73 | expect(mockStopMusic).toHaveBeenCalledTimes(0);
74 | expect(mockPlayMusic).toHaveBeenCalledTimes(1);
75 | expect(mockPlayMusic).toHaveBeenCalledWith('/assets/sound/test.mp3');
76 | expect(mockState.get().game.$currentMusic).toEqual(['sound/test.mp3']);
77 | playMusic('sound/test2.mp3');
78 | expect(mockStopMusic).toHaveBeenCalledTimes(0);
79 | expect(mockPlayMusic).toHaveBeenCalledTimes(2);
80 | expect(mockPlayMusic).toHaveBeenCalledWith('/assets/sound/test2.mp3');
81 | expect(mockState.get().game.$currentMusic).toEqual(['sound/test.mp3', 'sound/test2.mp3']);
82 | });
83 |
84 |
85 | test('playSingleMusic', () => {
86 | mockState.setSubkey('game', '$currentMusic', ['sound/m1.mp3', 'sound/m2.mp3']);
87 | expect(mockPlayMusic).toHaveBeenCalledTimes(0);
88 | expect(mockStopMusic).toHaveBeenCalledTimes(0);
89 |
90 | playSingleMusic('sound/test.mp3');
91 | // stops all music, plays only the music provided
92 | expect(mockStopMusic).toHaveBeenCalledTimes(1);
93 | expect(mockPlayMusic).toHaveBeenCalledTimes(1);
94 | expect(mockPlayMusic).toHaveBeenCalledWith('/assets/sound/test.mp3');
95 | expect(mockState.get().game.$currentMusic).toEqual(['sound/test.mp3']);
96 |
97 | playSingleMusic(['sound/test2.mp3', 'sound/test3.mp3']);
98 | // stops all music, plays only the last music provided
99 | expect(mockStopMusic).toHaveBeenCalledTimes(2);
100 | expect(mockPlayMusic).toHaveBeenCalledTimes(2);
101 | expect(mockPlayMusic).toHaveBeenCalledWith('/assets/sound/test3.mp3');
102 | expect(mockState.get().game.$currentMusic).toEqual(['sound/test3.mp3']);
103 | });
104 |
105 |
106 | test('stopMusic - specific file', () => {
107 | expect(mockStopMusic).toHaveBeenCalledTimes(0);
108 | playMusic(['sound/test.mp3', 'sound/test2.mp3']);
109 | stopMusic('sound/test.mp3');
110 | expect(mockStopMusic).toHaveBeenCalledTimes(1);
111 | expect(mockStopMusic).toHaveBeenCalledWith('/assets/sound/test.mp3');
112 | expect(mockState.get().game.$currentMusic).toEqual(['sound/test2.mp3']);
113 | stopMusic('sound/test2.mp3');
114 | expect(mockStopMusic).toHaveBeenCalledTimes(2);
115 | expect(mockStopMusic).toHaveBeenCalledWith('/assets/sound/test2.mp3');
116 | expect(mockState.get().game.$currentMusic).toEqual([]);
117 | });
118 |
119 | test('stopMusic - multiple files', () => {
120 | expect(mockStopMusic).toHaveBeenCalledTimes(0);
121 | playMusic(['sound/test.mp3', 'sound/test2.mp3']);
122 | stopMusic(['sound/test.mp3', 'sound/test2.mp3']);
123 | expect(mockStopMusic).toHaveBeenCalledTimes(2);
124 | expect(mockStopMusic).toHaveBeenCalledWith('/assets/sound/test.mp3');
125 | expect(mockStopMusic).toHaveBeenCalledWith('/assets/sound/test2.mp3');
126 | expect(mockState.get().game.$currentMusic).toEqual([]);
127 | });
128 |
129 | test('stopMusic without current music', () => {
130 | expect(mockStopMusic).toHaveBeenCalledTimes(0);
131 | stopMusic(['sound/test.mp3', 'sound/test2.mp3']);
132 | expect(mockStopMusic).toHaveBeenCalledTimes(2);
133 | expect(mockStopMusic).toHaveBeenCalledWith('/assets/sound/test.mp3');
134 | expect(mockStopMusic).toHaveBeenCalledWith('/assets/sound/test2.mp3');
135 | expect(mockState.get().game.$currentMusic).toEqual([]);
136 | });
137 |
138 | test('stopMusic - all music', () => {
139 | expect(mockStopMusic).toHaveBeenCalledTimes(0);
140 | playMusic(['sound/test.mp3', 'sound/test2.mp3']);
141 | stopMusic();
142 | expect(mockStopMusic).toHaveBeenCalledTimes(1);
143 | expect(mockStopMusic).toHaveBeenCalledWith();
144 | expect(mockState.get().game.$currentMusic).toEqual([]);
145 | });
146 | });
147 |
--------------------------------------------------------------------------------
/__tests__/index.test.js:
--------------------------------------------------------------------------------
1 | /* eslint-env jest */
2 | import atrament from '../src/index';
3 |
4 | import packageInfo from '../package.json';
5 | import { getConfig, setConfig } from '../src/utils/config';
6 | import { emitter, emit } from '../src/utils/emitter';
7 | import settings from '../src/components/settings';
8 |
9 | const filePath = '/path/to';
10 |
11 | const mockGetState = jest.fn(() => ({
12 | game: {
13 | $path: filePath
14 | }
15 | }));
16 |
17 | const mockStateStore = 'STORE';
18 |
19 | const mockState = {
20 | get: mockGetState,
21 | store: jest.fn(() => mockStateStore)
22 | };
23 |
24 | const mockPersistent = {
25 | init: jest.fn()
26 | };
27 |
28 | const mockInterfaces = {
29 | state: mockState,
30 | persistent: mockPersistent
31 | };
32 |
33 | jest.mock('../src/utils/interfaces', () => ({
34 | interfaces: jest.fn(() => mockInterfaces)
35 | }));
36 |
37 | jest.mock('../src/components/settings', () => ({
38 | load: jest.fn(async () => 'LOAD')
39 | }));
40 |
41 | jest.mock('../src/utils/emitter', () => ({
42 | emit: jest.fn(),
43 | emitter: {
44 | on: jest.fn(),
45 | off: jest.fn()
46 | }
47 | }));
48 |
49 | const Story = () => ({ inkStoryConstructor: true });
50 |
51 | beforeEach(() => {
52 | jest.clearAllMocks();
53 | setConfig(Story, { applicationID: 'testAppID' });
54 | });
55 |
56 | describe('atrament', () => {
57 | test('returns valid version', () => {
58 | const atramentVersion = atrament.version;
59 | expect(atramentVersion).toEqual(packageInfo.version);
60 | });
61 |
62 | test('returns interfaces', () => {
63 | const atramentInterfaces = atrament.interfaces;
64 | expect(atramentInterfaces).toEqual(mockInterfaces);
65 | });
66 |
67 | test('returns state', () => {
68 | const atramentState = atrament.state;
69 | expect(atramentState).toEqual(mockState);
70 | });
71 |
72 | test('returns store', () => {
73 | const atramentState = atrament.store;
74 | expect(atramentState).toEqual(mockStateStore);
75 | });
76 |
77 | test('subscribe/unsubscribe', () => {
78 | const callback = () => 'callback';
79 | expect(emitter.on).not.toHaveBeenCalled();
80 | atrament.on('event', callback);
81 | expect(emitter.on).toHaveBeenCalledWith('event', callback);
82 | expect(emitter.off).not.toHaveBeenCalled();
83 | atrament.off('event', callback);
84 | expect(emitter.off).toHaveBeenCalledWith('event', callback);
85 | });
86 |
87 | test('init', async () => {
88 | expect(emit).not.toHaveBeenCalled();
89 | expect(settings.load).not.toHaveBeenCalled();
90 | expect(mockPersistent.init).not.toHaveBeenCalled();
91 | expect(getConfig()).toEqual({
92 | InkStory: Story,
93 | applicationID: 'testAppID',
94 | settings: { mute: true, volume: 0 }
95 | });
96 | // run
97 | const cfg = { applicationID: 'TEST APP' };
98 | await atrament.init(Story, cfg);
99 | // check
100 | expect(getConfig()).toEqual({
101 | InkStory: Story,
102 | applicationID: cfg.applicationID,
103 | settings: { mute: true, volume: 0 }
104 | });
105 | expect(emit).toHaveBeenCalledWith('atrament/init');
106 | expect(mockPersistent.init).toHaveBeenCalledWith(cfg.applicationID);
107 | expect(settings.load).toHaveBeenCalledTimes(1);
108 | });
109 | });
110 |
--------------------------------------------------------------------------------
/__tests__/utils/config.test.js:
--------------------------------------------------------------------------------
1 | /* eslint-env jest */
2 | import { getConfig, setConfig } from '../../src/utils/config';
3 |
4 | const defaultConfig = {
5 | settings: {
6 | volume: 0,
7 | mute: true
8 | }
9 | };
10 |
11 | const Story = () => ({ inkStoryConstructor: true });
12 |
13 | afterAll(() => setConfig(Story, { ...defaultConfig, applicationID: 'jest-test' }));
14 |
15 | describe('utils/config', () => {
16 | test('no params', () => {
17 | expect(() => {
18 | setConfig();
19 | }).toThrow('atrament.init: provide ink Story constructor as a first argument!');
20 | });
21 |
22 | test('returns valid config', () => {
23 | const cfg = getConfig();
24 | expect(cfg).toEqual(defaultConfig);
25 | });
26 |
27 | test('no valid Story constructor provided', () => {
28 | expect(() => {
29 | setConfig({ some: 'config' });
30 | }).toThrow('atrament.init: Story is not a constructor!');
31 | });
32 |
33 | test('no config provided', () => {
34 | expect(() => setConfig(Story)).toThrow('atrament.init: config.applicationID is not set!');
35 | });
36 |
37 | test('config without applicationID', () => {
38 | expect(
39 | () => setConfig(Story, { settings: { mute: true } })
40 | ).toThrow('atrament.init: config.applicationID is not set!');
41 | });
42 |
43 | test('sets config', () => {
44 | setConfig(Story, {
45 | applicationID: 'jest-test',
46 | settings: {
47 | fullscreen: true
48 | }
49 | });
50 | const cfg = getConfig();
51 | expect(cfg).toEqual({
52 | applicationID: 'jest-test',
53 | InkStory: Story,
54 | settings: {
55 | fullscreen: true
56 | }
57 | });
58 | });
59 | });
60 |
--------------------------------------------------------------------------------
/__tests__/utils/emitter.test.js:
--------------------------------------------------------------------------------
1 | /* eslint-env jest */
2 | import { emitter, emit } from '../../src/utils/emitter';
3 |
4 | describe('utils/emitter', () => {
5 | test('subscribe, emit, unsubscribe', () => {
6 | const eventHandler = jest.fn();
7 |
8 | emit('event', 'eventParameter');
9 | expect(eventHandler).toBeCalledTimes(0);
10 |
11 | emitter.on('event', eventHandler);
12 | emit('event', 'eventParameter');
13 | expect(eventHandler).toBeCalledTimes(1);
14 | expect(eventHandler).toBeCalledWith('eventParameter');
15 | eventHandler.mockClear();
16 |
17 | emit('event', { key: 'value' });
18 | expect(eventHandler).toBeCalledTimes(1);
19 | expect(eventHandler).toBeCalledWith({ key: 'value' });
20 | eventHandler.mockClear();
21 |
22 | emitter.off('event', eventHandler);
23 | emit('event', 'eventParameter');
24 | expect(eventHandler).toBeCalledTimes(0);
25 | eventHandler.mockClear();
26 | });
27 | });
28 |
--------------------------------------------------------------------------------
/__tests__/utils/hashcode.test.js:
--------------------------------------------------------------------------------
1 | /* eslint-env jest */
2 | import hashCode from '../../src/utils/hashcode';
3 |
4 | describe('utils/hashcode', () => {
5 | test('returns valid hash', () => {
6 | const hashed = hashCode('test/string');
7 | expect(hashed).toBe(108729390);
8 | const hashed2 = hashCode('test/anotherstring');
9 | expect(hashed2).not.toBe(hashed);
10 | });
11 | });
12 |
--------------------------------------------------------------------------------
/__tests__/utils/interfaces.test.js:
--------------------------------------------------------------------------------
1 | /* eslint-env jest */
2 | import { interfaces, defineInterfaces } from '../../src/utils/interfaces';
3 |
4 | const defaultInterfaces = {
5 | loader: null,
6 | persistent: null,
7 | sound: {
8 | init: expect.any(Function),
9 | mute: expect.any(Function),
10 | isMuted: expect.any(Function),
11 | setVolume: expect.any(Function),
12 | getVolume: expect.any(Function),
13 | playSound: expect.any(Function),
14 | playMusic: expect.any(Function),
15 | stopMusic: expect.any(Function)
16 | },
17 | state: null
18 | };
19 |
20 | afterEach(() => defineInterfaces(defaultInterfaces));
21 |
22 | describe('utils/interfaces', () => {
23 | test('define some interfaces', () => {
24 | let i = interfaces();
25 | expect(i).toEqual(defaultInterfaces);
26 | defineInterfaces({
27 | loader: 'loaderInterface',
28 | persistent: 'persistentInterface'
29 | });
30 | i = interfaces();
31 | expect(i).toEqual({
32 | loader: 'loaderInterface',
33 | persistent: 'persistentInterface',
34 | sound: defaultInterfaces.sound,
35 | state: null
36 | });
37 | });
38 |
39 | test('define unknown interfaces', () => {
40 | let i = interfaces();
41 | expect(i).toEqual(defaultInterfaces);
42 | defineInterfaces({
43 | unknown: 'unknownInterface',
44 | persistent: 'persistentInterface'
45 | });
46 | i = interfaces();
47 | expect(i).toEqual({
48 | loader: null,
49 | persistent: 'persistentInterface',
50 | sound: defaultInterfaces.sound,
51 | state: null,
52 | unknown: 'unknownInterface'
53 | });
54 | });
55 | });
56 |
--------------------------------------------------------------------------------
/__tests__/utils/scene-processors.test.js:
--------------------------------------------------------------------------------
1 | /* eslint-env jest */
2 | import internalSceneProcessors from '../../src/utils/scene-processors';
3 |
4 |
5 | describe('utils/scene-processors', () => {
6 | describe('#IMAGE tags', () => {
7 | test('single image', () => {
8 | const scene = {
9 | content: [{
10 | text: 'aaa',
11 | tags: { IMAGE: 'img.jpg' }
12 | }],
13 | text: ['aaaa'],
14 | tags: {},
15 | choices: []
16 | };
17 | internalSceneProcessors.forEach((p) => p(scene));
18 | expect(scene).toEqual({
19 | content: [{
20 | text: 'aaa',
21 | tags: { IMAGE: 'img.jpg' },
22 | images: ['img.jpg'],
23 | sounds: [],
24 | music: []
25 | }],
26 | text: ['aaaa'],
27 | tags: {},
28 | choices: [],
29 | images: ['img.jpg'],
30 | sounds: [],
31 | music: []
32 | });
33 | });
34 |
35 | test('multiple images', () => {
36 | const scene = {
37 | content: [{
38 | text: 'aaa',
39 | tags: { IMAGE: ['img.jpg', 'img2.jpg'] }
40 | }],
41 | text: ['aaaa'],
42 | tags: {},
43 | choices: []
44 | };
45 | internalSceneProcessors.forEach((p) => p(scene));
46 | expect(scene).toEqual({
47 | content: [{
48 | text: 'aaa',
49 | tags: { IMAGE: ['img.jpg', 'img2.jpg'] },
50 | images: ['img.jpg', 'img2.jpg'],
51 | sounds: [],
52 | music: []
53 | }],
54 | text: ['aaaa'],
55 | tags: {},
56 | choices: [],
57 | images: ['img.jpg', 'img2.jpg'],
58 | sounds: [],
59 | music: []
60 | });
61 | });
62 |
63 | test('multiple images in different paragraphs', () => {
64 | const scene = {
65 | content: [{
66 | text: 'aaa',
67 | tags: { IMAGE: ['img.jpg', 'img2.jpg'] }
68 | }, {
69 | text: 'bbb',
70 | tags: { IMAGE: 'img3.jpg' }
71 | }],
72 | text: ['aaaa'],
73 | tags: {},
74 | choices: []
75 | };
76 | internalSceneProcessors.forEach((p) => p(scene));
77 | expect(scene).toEqual({
78 | content: [{
79 | text: 'aaa',
80 | tags: { IMAGE: ['img.jpg', 'img2.jpg'] },
81 | images: ['img.jpg', 'img2.jpg'],
82 | sounds: [],
83 | music: []
84 | }, {
85 | text: 'bbb',
86 | tags: { IMAGE: 'img3.jpg' },
87 | images: ['img3.jpg'],
88 | sounds: [],
89 | music: []
90 | }],
91 | text: ['aaaa'],
92 | tags: {},
93 | choices: [],
94 | images: ['img.jpg', 'img2.jpg', 'img3.jpg'],
95 | sounds: [],
96 | music: []
97 | });
98 | });
99 | });
100 |
101 | describe('sound and music tags', () => {
102 | test('single sound', () => {
103 | const scene = {
104 | content: [{
105 | text: 'aaa',
106 | tags: { AUDIO: 'sound.mp3', AUDIOLOOP: 'music.mp3' }
107 | }],
108 | text: ['aaaa'],
109 | tags: {},
110 | choices: []
111 | };
112 | internalSceneProcessors.forEach((p) => p(scene));
113 | expect(scene).toEqual({
114 | content: [{
115 | text: 'aaa',
116 | tags: { AUDIO: 'sound.mp3', AUDIOLOOP: 'music.mp3' },
117 | images: [],
118 | sounds: ['sound.mp3'],
119 | music: ['music.mp3']
120 | }],
121 | text: ['aaaa'],
122 | tags: {},
123 | choices: [],
124 | images: [],
125 | sounds: ['sound.mp3'],
126 | music: ['music.mp3']
127 | });
128 | });
129 |
130 | test('multiple sounds and music', () => {
131 | const scene = {
132 | content: [{
133 | text: 'aaa',
134 | tags: {
135 | PLAY_SOUND: ['sound1.mp3', 'sound2.mp3'],
136 | PLAY_MUSIC: ['music1.mp3', 'music2.mp3']
137 | }
138 | }],
139 | text: ['aaaa'],
140 | tags: {},
141 | choices: []
142 | };
143 | internalSceneProcessors.forEach((p) => p(scene));
144 | expect(scene).toEqual({
145 | content: [{
146 | text: 'aaa',
147 | tags: {
148 | PLAY_SOUND: ['sound1.mp3', 'sound2.mp3'],
149 | PLAY_MUSIC: ['music1.mp3', 'music2.mp3']
150 | },
151 | images: [],
152 | sounds: ['sound1.mp3', 'sound2.mp3'],
153 | music: ['music1.mp3', 'music2.mp3']
154 | }],
155 | text: ['aaaa'],
156 | tags: {},
157 | choices: [],
158 | images: [],
159 | sounds: ['sound1.mp3', 'sound2.mp3'],
160 | music: ['music1.mp3', 'music2.mp3']
161 | });
162 | });
163 |
164 | test('multiple images and music in different paragraphs', () => {
165 | const scene = {
166 | content: [{
167 | text: 'aaa',
168 | tags: {
169 | AUDIO: ['sound1.mp3', 'sound2.mp3'],
170 | AUDIOLOOP: 'music1.mp3'
171 | }
172 | }, {
173 | text: 'bbb',
174 | tags: {
175 | AUDIO: 'audio.mp3',
176 | PLAY_SOUND: 'sound3.mp3',
177 | PLAY_MUSIC: ['music2.mp3', 'music3.mp3']
178 | }
179 | }],
180 | text: ['aaaa'],
181 | tags: {},
182 | choices: []
183 | };
184 | internalSceneProcessors.forEach((p) => p(scene));
185 | expect(scene).toEqual({
186 | content: [{
187 | text: 'aaa',
188 | tags: {
189 | AUDIO: ['sound1.mp3', 'sound2.mp3'],
190 | AUDIOLOOP: 'music1.mp3'
191 | },
192 | images: [],
193 | sounds: ['sound1.mp3', 'sound2.mp3'],
194 | music: ['music1.mp3']
195 | }, {
196 | text: 'bbb',
197 | tags: {
198 | AUDIO: 'audio.mp3',
199 | PLAY_SOUND: 'sound3.mp3',
200 | PLAY_MUSIC: ['music2.mp3', 'music3.mp3']
201 | },
202 | images: [],
203 | sounds: ['audio.mp3', 'sound3.mp3'],
204 | music: ['music2.mp3', 'music3.mp3']
205 | }],
206 | text: ['aaaa'],
207 | tags: {},
208 | choices: [],
209 | images: [],
210 | sounds: ['sound1.mp3', 'sound2.mp3', 'audio.mp3', 'sound3.mp3'],
211 | music: ['music1.mp3', 'music2.mp3', 'music3.mp3']
212 | });
213 | });
214 | });
215 |
216 | describe('disabled choices', () => {
217 | function testChoiceTag(choiceTag) {
218 | const scene = {
219 | content: [{
220 | text: 'aaa',
221 | tags: {}
222 | }],
223 | text: ['aaaa'],
224 | tags: {},
225 | choices: [{
226 | id: 1,
227 | choice: 'ccc',
228 | tags: { [choiceTag]: true }
229 | },
230 | {
231 | id: 2,
232 | choice: 'ddd',
233 | tags: {}
234 | }]
235 | };
236 | internalSceneProcessors.forEach((p) => p(scene));
237 | expect(scene).toEqual({
238 | content: [{
239 | text: 'aaa',
240 | tags: {},
241 | images: [],
242 | sounds: [],
243 | music: []
244 | }],
245 | text: ['aaaa'],
246 | tags: {},
247 | choices: [{
248 | id: 1,
249 | choice: 'ccc',
250 | tags: { [choiceTag]: true },
251 | disabled: true
252 | },
253 | {
254 | id: 2,
255 | choice: 'ddd',
256 | tags: {}
257 | }],
258 | images: [],
259 | sounds: [],
260 | music: []
261 | });
262 | }
263 |
264 | test('UNCLICKABLE', () => {
265 | testChoiceTag('UNCLICKABLE');
266 | });
267 |
268 | test('DISABLED', () => {
269 | testChoiceTag('DISABLED');
270 | });
271 |
272 | test('INACTIVE', () => {
273 | testChoiceTag('INACTIVE');
274 | });
275 | });
276 | });
277 |
--------------------------------------------------------------------------------
/__tests__/utils/tags.test.js:
--------------------------------------------------------------------------------
1 | /* eslint-env jest */
2 | import mockConsole from 'jest-mock-console'; /* eslint-disable-line */
3 | import { parseTags } from '../../src/utils/tags';
4 |
5 | describe('utils/tags', () => {
6 | test('no tags', () => {
7 | const tags = parseTags();
8 | expect(tags).toEqual({});
9 | });
10 |
11 | test('simple tags', () => {
12 | const tags = parseTags(['HELLO', 'WORLD']);
13 | expect(tags).toEqual({
14 | HELLO: true,
15 | WORLD: true
16 | });
17 | });
18 |
19 | test('parameterized tags', () => {
20 | const tags = parseTags(['TAG1: named', 'TAG2: config:string']);
21 | expect(tags).toEqual({
22 | TAG1: 'named',
23 | TAG2: 'config:string'
24 | });
25 | });
26 |
27 | test('parameterized tags - boolean', () => {
28 | const tags = parseTags(['TAG1: true', 'TAG2: false']);
29 | expect(tags).toEqual({
30 | TAG1: true,
31 | TAG2: false
32 | });
33 | });
34 |
35 | test('parameterized tags - strings', () => {
36 | const tags = parseTags(['TAG1: "true"', 'TAG2: "false"']);
37 | expect(tags).toEqual({
38 | TAG1: 'true',
39 | TAG2: 'false'
40 | });
41 | });
42 |
43 | test('JSON tags', () => {
44 | const tags = parseTags(['JSONOBJECT: {"key":"value"}', 'JSONARRAY: ["hello", "world"]']);
45 | expect(tags).toEqual({
46 | JSONOBJECT: { key: 'value' },
47 | JSONARRAY: ['hello', 'world']
48 | });
49 | });
50 |
51 | test('JSON tags - ignore invalid JSON', () => {
52 | const restoreConsole = mockConsole();
53 | const tags = parseTags(['JSONOBJECT: {"key":}', 'JSONARRAY: ["hello", "world"', 'VALIDJSON: {"key":"value"}']);
54 | expect(tags).toEqual({
55 | VALIDJSON: { key: 'value' }
56 | });
57 | restoreConsole();
58 | });
59 |
60 | test('multiple tags', () => {
61 | const tags = parseTags(['title: Game Title', 'observe: var1', 'observe: var2', 'observe: var3']);
62 | expect(tags).toEqual({
63 | title: 'Game Title',
64 | observe: ['var1', 'var2', 'var3']
65 | });
66 | });
67 |
68 | test('combined', () => {
69 | const restoreConsole = mockConsole();
70 | const tags = parseTags([
71 | 'HELLO',
72 | 'BOOLEANTAG: false',
73 | 'MALFORMEDJSONARRAY: ["hello", "world"',
74 | 'title: Game Title',
75 | 'JSONARRAY: ["hello", "world"]',
76 | 'observe: var1',
77 | 'MALFORMEDJSONOBJECT: {"key":}',
78 | 'WORLD',
79 | 'observe: var2',
80 | 'JSONOBJECT: {"key":"value"}',
81 | 'observe: var3'
82 | ]);
83 | expect(tags).toEqual({
84 | HELLO: true,
85 | WORLD: true,
86 | BOOLEANTAG: false,
87 | JSONOBJECT: { key: 'value' },
88 | JSONARRAY: ['hello', 'world'],
89 | title: 'Game Title',
90 | observe: ['var1', 'var2', 'var3']
91 | });
92 | restoreConsole();
93 | });
94 | });
95 |
--------------------------------------------------------------------------------
/babel.config.js:
--------------------------------------------------------------------------------
1 | module.exports = (api) => {
2 | // api.cache(true);
3 | const isTest = api.env('test');
4 | const presetEnv = {
5 | targets: {
6 | browsers: ['>0.5%', 'not ie 11', 'not op_mini all']
7 | }
8 | };
9 | if (isTest) {
10 | // fix issue with Story constructor in class AtramentStory
11 | // presetEnv.exclude = ['@babel/plugin-transform-classes'];
12 | presetEnv.targets = { node: 'current' };
13 | }
14 | return {
15 | presets: [
16 | ['@babel/preset-env', presetEnv]
17 | ]
18 | };
19 | };
20 |
--------------------------------------------------------------------------------
/jest.config.js:
--------------------------------------------------------------------------------
1 | /* eslint max-len: "off" */
2 | // For a detailed explanation regarding each configuration property, visit:
3 | // https://jestjs.io/docs/en/configuration.html
4 |
5 | module.exports = {
6 | // All imported modules in your tests should be mocked automatically
7 | // automock: false,
8 |
9 | // Stop running tests after `n` failures
10 | // bail: 0,
11 |
12 | // Respect "browser" field in package.json when resolving modules
13 | // browser: false,
14 |
15 | // The directory where Jest should store its cached dependency information
16 | // cacheDirectory: "C:\\Users\\techniX\\AppData\\Local\\Temp\\jest",
17 |
18 | // Automatically clear mock calls and instances between every test
19 | clearMocks: true,
20 |
21 | // Indicates whether the coverage information should be collected while executing the test
22 | collectCoverage: true,
23 |
24 | // An array of glob patterns indicating a set of files for which coverage information should be collected
25 | collectCoverageFrom: ['src/**'],
26 |
27 | // The directory where Jest should output its coverage files
28 | coverageDirectory: 'coverage',
29 |
30 | // An array of regexp pattern strings used to skip coverage collection
31 | // coveragePathIgnorePatterns: [
32 | // "\\\\node_modules\\\\"
33 | // ],
34 |
35 | // A list of reporter names that Jest uses when writing coverage reports
36 | // coverageReporters: [
37 | // "json",
38 | // "text",
39 | // "lcov",
40 | // "clover"
41 | // ],
42 |
43 | // An object that configures minimum threshold enforcement for coverage results
44 | // coverageThreshold: null,
45 |
46 | // A path to a custom dependency extractor
47 | // dependencyExtractor: null,
48 |
49 | // Make calling deprecated APIs throw helpful error messages
50 | // errorOnDeprecated: false,
51 |
52 | // Force coverage collection from ignored files using an array of glob patterns
53 | // forceCoverageMatch: [],
54 |
55 | // A path to a module which exports an async function that is triggered once before all test suites
56 | // globalSetup: null,
57 |
58 | // A path to a module which exports an async function that is triggered once after all test suites
59 | // globalTeardown: null,
60 |
61 | // A set of global variables that need to be available in all test environments
62 | // globals: {},
63 |
64 | // An array of directory names to be searched recursively up from the requiring module's location
65 | // moduleDirectories: [
66 | // "node_modules"
67 | // ],
68 |
69 | // An array of file extensions your modules use
70 | // moduleFileExtensions: [
71 | // "js",
72 | // "json",
73 | // "jsx",
74 | // "ts",
75 | // "tsx",
76 | // "node"
77 | // ],
78 |
79 | // A map from regular expressions to module names that allow to stub out resources with a single module
80 | // moduleNameMapper: {},
81 |
82 | // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader
83 | // modulePathIgnorePatterns: [],
84 |
85 | // Activates notifications for test results
86 | // notify: false,
87 |
88 | // An enum that specifies notification mode. Requires { notify: true }
89 | // notifyMode: "failure-change",
90 |
91 | // A preset that is used as a base for Jest's configuration
92 | // preset: null,
93 |
94 | // Run tests from one or more projects
95 | // projects: null,
96 |
97 | // Use this configuration option to add custom reporters to Jest
98 | // reporters: undefined,
99 |
100 | // Automatically reset mock state between every test
101 | // resetMocks: false,
102 |
103 | // Reset the module registry before running each individual test
104 | // resetModules: false,
105 |
106 | // A path to a custom resolver
107 | // resolver: null,
108 |
109 | // Automatically restore mock state between every test
110 | // restoreMocks: false,
111 |
112 | // The root directory that Jest should scan for tests and modules within
113 | // rootDir: null,
114 |
115 | // A list of paths to directories that Jest should use to search for files in
116 | // roots: [
117 | // '/src/',
118 | // '/__tests__/'
119 | // ],
120 |
121 | // Allows you to use a custom runner instead of Jest's default test runner
122 | // runner: "jest-runner",
123 |
124 | // The paths to modules that run some code to configure or set up the testing environment before each test
125 | // setupFiles: [],
126 |
127 | // A list of paths to modules that run some code to configure or set up the testing framework before each test
128 | // setupFilesAfterEnv: [],
129 |
130 | // A list of paths to snapshot serializer modules Jest should use for snapshot testing
131 | // snapshotSerializers: [],
132 |
133 | // The test environment that will be used for testing
134 | testEnvironment: 'node',
135 |
136 | // Options that will be passed to the testEnvironment
137 | // testEnvironmentOptions: {},
138 |
139 | // Adds a location field to test results
140 | // testLocationInResults: false,
141 |
142 | // The glob patterns Jest uses to detect test files
143 | // testMatch: [
144 | // "**/__tests__/**/*.[jt]s?(x)",
145 | // "**/?(*.)+(spec|test).[tj]s?(x)"
146 | // ],
147 |
148 | // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped
149 | // testPathIgnorePatterns: [
150 | // "\\\\node_modules\\\\"
151 | // ],
152 |
153 | // The regexp pattern or array of patterns that Jest uses to detect test files
154 | // testRegex: [],
155 |
156 | // This option allows the use of a custom results processor
157 | // testResultsProcessor: null,
158 |
159 | // This option allows use of a custom test runner
160 | // testRunner: "jasmine2",
161 |
162 | // This option sets the URL for the jsdom environment. It is reflected in properties such as location.href
163 | // testURL: "http://localhost",
164 |
165 | // Setting this value to "fake" allows the use of fake timers for functions such as "setTimeout"
166 | // timers: "real",
167 |
168 | // A map from regular expressions to paths to transformers
169 | // transform: null,
170 |
171 | // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation
172 | transformIgnorePatterns: [
173 | 'node_modules/(?!(nanostores|@nanostores)/)'
174 | ]
175 |
176 | // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them
177 | // unmockedModulePathPatterns: undefined,
178 |
179 | // Indicates whether each individual test should be reported during the run
180 | // verbose: null,
181 |
182 | // An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode
183 | // watchPathIgnorePatterns: [],
184 |
185 | // Whether to use watchman for file crawling
186 | // watchman: true,
187 | };
188 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@atrament/core",
3 | "version": "2.0.0",
4 | "description": "Atrament, a text game engine - core module",
5 | "main": "src/index.js",
6 | "files": [
7 | "src"
8 | ],
9 | "scripts": {
10 | "test": "jest",
11 | "lint": "eslint . --ext .js"
12 | },
13 | "author": "Serhii Mozhaiskyi (sergei.mozhaisky@gmail.com)",
14 | "repository": {
15 | "type": "git",
16 | "url": "git+https://github.com/technix/atrament-core.git"
17 | },
18 | "license": "MIT",
19 | "dependencies": {
20 | "mitt": "3.0.1"
21 | },
22 | "devDependencies": {
23 | "@babel/core": "7.25.2",
24 | "@babel/eslint-parser": "7.25.1",
25 | "@babel/preset-env": "7.25.4",
26 | "@babel/register": "7.24.6",
27 | "eslint": "8.49.0",
28 | "eslint-config-airbnb-base": "15.0.0",
29 | "eslint-plugin-import": "2.29.1",
30 | "jest": "29.7.0",
31 | "jest-mock-console": "2.0.0"
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/src/components/game/control.js:
--------------------------------------------------------------------------------
1 | import { interfaces } from '../../utils/interfaces';
2 | import { emit } from '../../utils/emitter';
3 |
4 | import hashCode from '../../utils/hashcode';
5 | import toArray from '../../utils/to-array';
6 |
7 | import ink from '../ink';
8 | import { persistentPrefix, load, existSave } from '../saves';
9 | import { playMusic, stopMusic } from '../sound';
10 |
11 |
12 | let expectedInkScriptUUID = null;
13 | let currentInkScriptUUID = null;
14 |
15 |
16 | function $clearGameState() {
17 | const { state } = interfaces();
18 | // reset state
19 | state.setKey('scenes', []);
20 | state.setKey('vars', {});
21 | }
22 |
23 |
24 | function $iterateObservers(observerHandler) {
25 | const { state } = interfaces();
26 | const observers = state.get().metadata.observe;
27 | if (observers) {
28 | toArray(observers).forEach(observerHandler);
29 | }
30 | }
31 |
32 |
33 | const persistentVarState = {};
34 |
35 | async function $handlePersistent() {
36 | const { state, persistent } = interfaces();
37 | const { game, metadata } = state.get();
38 | const persistentVars = metadata.persist;
39 | if (persistentVars) {
40 | const storeID = persistentPrefix('persist');
41 | // load persistent data, if possible
42 | if (await persistent.exists(storeID)) {
43 | persistentVarState[game.$gameUUID] = await persistent.get(storeID);
44 | Object.entries(persistentVarState[game.$gameUUID]).forEach(
45 | ([k, v]) => ink.setVariable(k, v)
46 | );
47 | } else if (!persistentVarState[game.$gameUUID]) {
48 | persistentVarState[game.$gameUUID] = {};
49 | }
50 | // register observers for persistent vars
51 | toArray(persistentVars).forEach((variable) => {
52 | ink.observeVariable(variable, async (name, value) => {
53 | persistentVarState[game.$gameUUID][name] = value;
54 | await persistent.set(storeID, persistentVarState[game.$gameUUID]);
55 | });
56 | });
57 | }
58 | }
59 |
60 | // ===========================================================
61 |
62 | export async function init(pathToInkFile, inkFile, gameID) {
63 | await interfaces().loader.init(pathToInkFile);
64 | const gameObj = {
65 | $path: pathToInkFile,
66 | $file: inkFile,
67 | $gameUUID: gameID || hashCode(`${pathToInkFile}|${inkFile}`)
68 | };
69 | interfaces().state.setKey('game', gameObj);
70 | expectedInkScriptUUID = gameObj.$gameUUID; // expecting to load content with this UUID
71 | emit('game/init', { pathToInkFile, inkFile });
72 | }
73 |
74 |
75 | export async function loadInkFile() {
76 | const { game } = interfaces().state.get();
77 | let inkContent = await interfaces().loader.loadInk(game.$file);
78 | if (typeof inkContent === 'string') {
79 | inkContent = JSON.parse(inkContent.replace('\uFEFF', ''));
80 | }
81 | emit('game/loadInkFile', game.$file);
82 | return inkContent;
83 | }
84 |
85 |
86 | export async function initInkStory() {
87 | const { state } = interfaces();
88 | const inkContent = await loadInkFile();
89 | // initialize InkJS
90 | ink.initStory(inkContent);
91 | // read global tags
92 | const metadata = ink.getGlobalTags();
93 | state.setKey('metadata', metadata);
94 | // register variable observers
95 | $iterateObservers((variable) => {
96 | ink.observeVariable(variable, (name, value) => {
97 | state.setSubkey('vars', name, value);
98 | emit('ink/variableObserver', { name, value });
99 | });
100 | });
101 | currentInkScriptUUID = expectedInkScriptUUID;
102 | emit('game/initInkStory');
103 | }
104 |
105 |
106 | export async function start(saveSlot) {
107 | const { state } = interfaces();
108 | stopMusic(); // stop all music
109 | $clearGameState(); // game state cleanup
110 | if (currentInkScriptUUID !== expectedInkScriptUUID) {
111 | // ink content is not from the same game, reload
112 | await initInkStory();
113 | }
114 | // load saved game, if present
115 | if (saveSlot) {
116 | if (await existSave(saveSlot)) {
117 | await load(saveSlot);
118 | const { game } = state.get();
119 | // restore music
120 | if (game.$currentMusic) {
121 | playMusic(game.$currentMusic);
122 | }
123 | }
124 | }
125 | await $handlePersistent();
126 | // read initial state of observed variables
127 | $iterateObservers((variable) => {
128 | state.setSubkey('vars', variable, ink.getVariable(variable));
129 | });
130 | emit('game/start', { saveSlot });
131 | }
132 |
133 |
134 | export function clear() {
135 | stopMusic(); // stop all music
136 | $clearGameState();
137 | ink.resetStory(); // reset ink story state
138 | emit('game/clear');
139 | }
140 |
141 | export function reset() {
142 | const { state } = interfaces();
143 | clear();
144 | // clean metadata and game
145 | state.setKey('metadata', {});
146 | state.setKey('game', {});
147 | emit('game/reset');
148 | }
149 |
--------------------------------------------------------------------------------
/src/components/game/index.js:
--------------------------------------------------------------------------------
1 | import { interfaces } from '../../utils/interfaces';
2 | import { emit } from '../../utils/emitter';
3 |
4 | import { init, loadInkFile, initInkStory, start, clear, reset } from './control';
5 | import { getSession, setSession, getSessions, removeSession } from './sessions';
6 |
7 | import ink from '../ink';
8 | import { playSound, stopSound, playMusic, playSingleMusic, stopMusic } from '../sound';
9 | import {
10 | getSaveSlotKey,
11 | load,
12 | save,
13 | existSave,
14 | removeSave,
15 | listSaves,
16 | SAVE_GAME,
17 | SAVE_AUTOSAVE,
18 | SAVE_CHECKPOINT
19 | } from '../saves';
20 |
21 | import internalSceneProcessors from '../../utils/scene-processors';
22 |
23 | const sceneProcessors = [];
24 |
25 | // ===========================================
26 |
27 | async function canResume() {
28 | let saveSlot = null;
29 | const autosaveSlot = getSaveSlotKey({ type: SAVE_AUTOSAVE });
30 | if (await existSave(autosaveSlot)) {
31 | saveSlot = autosaveSlot;
32 | } else {
33 | const saves = await listSaves();
34 | const checkpoints = saves.filter((k) => k.type === SAVE_CHECKPOINT);
35 | if (checkpoints.length) {
36 | saveSlot = checkpoints.sort((a, b) => b.date - a.date)[0].id;
37 | }
38 | }
39 | emit('game/canResume', saveSlot);
40 | return saveSlot;
41 | }
42 |
43 |
44 | async function resume() {
45 | const saveSlot = await canResume();
46 | emit('game/resume', { saveSlot });
47 | ink.resetStory(); // reset ink story state
48 | await start(saveSlot);
49 | }
50 |
51 |
52 | async function restart(saveSlot) {
53 | emit('game/restart', { saveSlot });
54 | ink.resetStory(); // reset ink story state
55 | await start(saveSlot);
56 | }
57 |
58 |
59 | async function restartAndContinue(saveSlot) {
60 | await restart(saveSlot);
61 | continueStory();
62 | }
63 |
64 |
65 | async function saveGame(name) {
66 | await save({ type: SAVE_GAME, name });
67 | }
68 |
69 |
70 | async function saveCheckpoint(name) {
71 | await save({ type: SAVE_CHECKPOINT, name });
72 | }
73 |
74 |
75 | async function saveAutosave() {
76 | await save({ type: SAVE_AUTOSAVE });
77 | }
78 |
79 |
80 | const tagHandlers = {
81 | CLEAR: () => interfaces().state.setKey('scenes', []),
82 | AUDIO: (v) => (v ? playSound(v) : stopSound()),
83 | AUDIOLOOP: (v) => (v ? playSingleMusic(v) : stopMusic()),
84 | PLAY_SOUND: playSound,
85 | STOP_SOUND: (v) => (v === true ? stopSound() : stopSound(v)),
86 | PLAY_MUSIC: playMusic,
87 | STOP_MUSIC: (v) => (v === true ? stopMusic() : stopMusic(v)),
88 | CHECKPOINT: (v) => saveCheckpoint(v),
89 | SAVEGAME: (v) => saveGame(v)
90 | };
91 |
92 |
93 | function $processTags(list, tags) {
94 | list.forEach((tag) => {
95 | if (tag in tags && tag in tagHandlers) {
96 | tagHandlers[tag](tags[tag]);
97 | emit('game/handletag', { [tag]: tags[tag] });
98 | }
99 | });
100 | }
101 |
102 |
103 | function defineSceneProcessor(fn) {
104 | sceneProcessors.push(fn);
105 | }
106 |
107 |
108 | function continueStory() {
109 | const { state } = interfaces();
110 | const { metadata } = state.get();
111 | // get next scene
112 | const isContinueMaximally = !(metadata.continue_maximally === false);
113 | const scene = ink.getScene(isContinueMaximally);
114 | if (scene.content.length === 0) {
115 | /*
116 | if we have a scene with empty content
117 | (usually it happens after state load)
118 | do not process it further
119 | */
120 | return;
121 | }
122 | // run scene processors
123 | internalSceneProcessors.forEach((p) => p(scene));
124 | sceneProcessors.forEach((p) => p(scene));
125 | // process tags
126 | const { tags } = scene;
127 |
128 | // RESTART
129 | if (tags.RESTART) {
130 | restart();
131 | return;
132 | }
133 |
134 | // RESTART_FROM_CHECKPOINT
135 | if (tags.RESTART_FROM_CHECKPOINT) {
136 | restart(getSaveSlotKey({ type: 'checkpoint', name: tags.RESTART_FROM_CHECKPOINT }));
137 | return;
138 | }
139 |
140 | // tags to do pre-render actions
141 | $processTags(
142 | ['CLEAR', 'STOP_SOUND', 'STOP_MUSIC', 'PLAY_SOUND', 'PLAY_MUSIC', 'AUDIO', 'AUDIOLOOP'],
143 | tags
144 | );
145 |
146 | if (metadata.single_scene) {
147 | // put single scene to state
148 | state.setKey('scenes', [scene]);
149 | } else {
150 | // add scene to state
151 | state.appendKey('scenes', scene);
152 | }
153 |
154 | // handle game saves
155 | $processTags(
156 | ['CHECKPOINT', 'SAVEGAME'],
157 | tags
158 | );
159 |
160 | // if autosave mode is not disabled
161 | if (metadata.autosave !== false) {
162 | saveAutosave();
163 | }
164 | emit('game/continueStory');
165 | }
166 |
167 |
168 | export default {
169 | // game-control
170 | init,
171 | loadInkFile,
172 | initInkStory,
173 | start,
174 | clear,
175 | reset,
176 | // game
177 | resume,
178 | canResume,
179 | restart,
180 | restartAndContinue,
181 | continueStory,
182 | makeChoice: (id) => ink.makeChoice(id),
183 | getAssetPath: (path) => interfaces().loader.getAssetPath(path),
184 | defineSceneProcessor,
185 | // saves
186 | SAVE_GAME,
187 | SAVE_AUTOSAVE,
188 | SAVE_CHECKPOINT,
189 | getSaveSlotKey,
190 | saveGame,
191 | saveCheckpoint,
192 | saveAutosave,
193 | load,
194 | listSaves,
195 | removeSave,
196 | existSave,
197 | // sessions
198 | getSession,
199 | setSession,
200 | getSessions,
201 | removeSession
202 | };
203 |
--------------------------------------------------------------------------------
/src/components/game/sessions.js:
--------------------------------------------------------------------------------
1 | import { emit } from '../../utils/emitter';
2 | import { interfaces } from '../../utils/interfaces';
3 |
4 | async function $iteratePersistent(callback) {
5 | const { persistent } = interfaces();
6 | const keys = await persistent.keys();
7 | await Promise.all(
8 | keys.map(
9 | async (key) => {
10 | if (!key.includes('save')) {
11 | return; // this is not a saved game
12 | }
13 | const saveData = await persistent.get(key);
14 | await callback(key, saveData);
15 | }
16 | )
17 | );
18 | }
19 |
20 | // =============================================
21 |
22 | export function validSession(v) {
23 | if (typeof v === 'number' || typeof v === 'string') {
24 | return v;
25 | }
26 | return '';
27 | }
28 |
29 | export function getSession() {
30 | const { state } = interfaces();
31 | return validSession(state.get().game.$sessionID);
32 | }
33 |
34 | export function setSession(sessionName) {
35 | const { state } = interfaces();
36 | const session = validSession(sessionName);
37 | state.setSubkey('game', '$sessionID', session);
38 | emit('game/setSession', session);
39 | }
40 |
41 | export async function getSessions() {
42 | const sessions = {};
43 | await $iteratePersistent(async (key, saveData) => {
44 | const sessionID = validSession(saveData.game.$sessionID);
45 | if (sessions[sessionID]) {
46 | sessions[sessionID] += 1;
47 | } else {
48 | sessions[sessionID] = 1;
49 | }
50 | });
51 | emit('game/getSessions', sessions);
52 | return sessions;
53 | }
54 |
55 | export async function removeSession(sessionName) {
56 | const { persistent } = interfaces();
57 | const session = validSession(sessionName);
58 | await $iteratePersistent(async (key, saveData) => {
59 | if (validSession(saveData.game.$sessionID) === session) {
60 | await persistent.remove(key);
61 | }
62 | });
63 | emit('game/deleteSession', session);
64 | }
65 |
--------------------------------------------------------------------------------
/src/components/ink.js:
--------------------------------------------------------------------------------
1 | import { parseTags } from '../utils/tags';
2 | import { getConfig } from '../utils/config';
3 | import { emit } from '../utils/emitter';
4 |
5 | let inkStory = null;
6 |
7 |
8 | function initStory(content) {
9 | const { InkStory } = getConfig();
10 | inkStory = new InkStory(content);
11 | emit('ink/initStory');
12 | }
13 |
14 |
15 | function resetStory() {
16 | let success = false;
17 | if (inkStory) {
18 | inkStory.ResetState();
19 | success = true;
20 | }
21 | emit('ink/resetStory', success);
22 | }
23 |
24 |
25 | function $continue(story, scene) {
26 | if (!story.canContinue) {
27 | return;
28 | }
29 | story.Continue();
30 | const text = story.currentText;
31 | // add story text
32 | scene.text.push(text);
33 | // add tags
34 | const tags = parseTags(story.currentTags);
35 | scene.tags = { ...scene.tags, ...tags };
36 | // save content - text along with tags
37 | scene.content.push({ text, tags });
38 | if (text === '\n') {
39 | // there was an empty line, try again to get some text
40 | $continue(story, scene);
41 | }
42 | }
43 |
44 |
45 | // get scene from ink
46 | function getScene(continueMaximally) {
47 | const scene = {
48 | content: [],
49 | text: [],
50 | tags: {},
51 | choices: [],
52 | uuid: Date.now()
53 | };
54 | if (continueMaximally) {
55 | while (inkStory.canContinue) {
56 | $continue(inkStory, scene);
57 | }
58 | } else {
59 | $continue(inkStory, scene);
60 | scene.canContinue = inkStory.canContinue;
61 | }
62 | inkStory.currentChoices.forEach((choice, id) => {
63 | scene.choices.push({
64 | id,
65 | choice: choice.text,
66 | tags: parseTags(choice.tags)
67 | });
68 | });
69 | emit('ink/getScene', scene);
70 | return scene;
71 | }
72 |
73 |
74 | export default {
75 | initStory,
76 | resetStory,
77 | story: () => inkStory,
78 | loadState: (savedState) => inkStory.state.LoadJson(savedState),
79 | getState: () => inkStory.state.toJson(),
80 | makeChoice: (id) => {
81 | inkStory.ChooseChoiceIndex(id);
82 | emit('ink/makeChoice', id);
83 | },
84 | getVisitCount: (ref) => {
85 | const visitCount = inkStory.VisitCountAtPathString(ref);
86 | emit('ink/getVisitCount', { ref, visitCount });
87 | return visitCount;
88 | },
89 | evaluateFunction: (fn, args, returnTextOutput) => {
90 | const result = inkStory.EvaluateFunction(fn, args, returnTextOutput);
91 | emit('ink/evaluateFunction', { function: fn, args, result });
92 | return result;
93 | },
94 | getGlobalTags: () => {
95 | const globalTags = parseTags(inkStory.globalTags);
96 | emit('ink/getGlobalTags', globalTags);
97 | return globalTags;
98 | },
99 | getVariable: (name) => {
100 | const value = inkStory.variablesState[name];
101 | emit('ink/getVariable', { name, value });
102 | return value;
103 | },
104 | getVariables: () => {
105 | const varState = inkStory.variablesState;
106 | const inkVars = {};
107 | varState._globalVariables.forEach(/* eslint-disable-line no-underscore-dangle */
108 | (value, key) => { inkVars[key] = varState[key]; }
109 | );
110 | emit('ink/getVariables', inkVars);
111 | return inkVars;
112 | },
113 | setVariable: (name, value) => {
114 | inkStory.variablesState[name] = value;
115 | emit('ink/setVariable', { name, value });
116 | },
117 | observeVariable: (variable, observer) => inkStory.ObserveVariable(variable, observer),
118 | goTo: (knot) => {
119 | inkStory.ChoosePathString(knot);
120 | emit('ink/goTo', knot);
121 | },
122 | onError: (callback) => {
123 | inkStory.onError = (error) => {
124 | emit('ink/onError', error);
125 | callback(error);
126 | };
127 | },
128 | getScene
129 | };
130 |
--------------------------------------------------------------------------------
/src/components/saves.js:
--------------------------------------------------------------------------------
1 | import ink from './ink';
2 | import { interfaces } from '../utils/interfaces';
3 | import { emit } from '../utils/emitter';
4 |
5 | import { validSession } from './game/sessions';
6 |
7 | export const SAVE_GAME = 'game';
8 | export const SAVE_AUTOSAVE = 'autosave';
9 | export const SAVE_CHECKPOINT = 'checkpoint';
10 |
11 | export function persistentPrefix(section) {
12 | const { $gameUUID, $sessionID } = interfaces().state.get().game;
13 | return [
14 | $gameUUID,
15 | validSession($sessionID),
16 | section
17 | ].join('/');
18 | }
19 |
20 |
21 | export function getSaveSlotKey({ name, type }) {
22 | return [
23 | persistentPrefix('save'),
24 | [SAVE_GAME, SAVE_AUTOSAVE, SAVE_CHECKPOINT].includes(type) ? type : SAVE_GAME,
25 | typeof name === 'string' || typeof name === 'number' ? name : ''
26 | ].join('/');
27 | }
28 |
29 |
30 | export async function load(s) {
31 | let saveSlotKey = s;
32 | if (typeof s === 'object') {
33 | saveSlotKey = getSaveSlotKey(s);
34 | }
35 | const { persistent, state } = interfaces();
36 | const gameState = await persistent.get(saveSlotKey);
37 | state.setKey('scenes', gameState.scenes);
38 | state.setKey('game', gameState.game);
39 | ink.loadState(gameState.state);
40 | emit('game/load', saveSlotKey);
41 | }
42 |
43 |
44 | export async function save({ name, type }) {
45 | const { state, persistent } = interfaces();
46 | const atramentState = state.get();
47 | const gameState = {
48 | name,
49 | type,
50 | date: Date.now(),
51 | state: ink.getState(),
52 | game: atramentState.game,
53 | scenes: atramentState.scenes
54 | };
55 | const saveSlotKey = getSaveSlotKey({ name, type });
56 | await persistent.set(saveSlotKey, gameState);
57 | emit('game/save', saveSlotKey);
58 | }
59 |
60 |
61 | export async function existSave(saveSlotKey) {
62 | const saveExists = await interfaces().persistent.exists(saveSlotKey);
63 | return saveExists;
64 | }
65 |
66 |
67 | export async function removeSave(saveSlotKey) {
68 | await interfaces().persistent.remove(saveSlotKey);
69 | emit('game/removeSave', saveSlotKey);
70 | }
71 |
72 |
73 | export async function listSaves() {
74 | const { persistent } = interfaces();
75 | const keys = await persistent.keys();
76 | const saves = keys.filter((k) => k.includes(persistentPrefix('save')));
77 | const savesList = await Promise.all(
78 | saves.map(
79 | async (key) => {
80 | const saveData = await persistent.get(key);
81 | saveData.id = key;
82 | delete saveData.state;
83 | delete saveData.scenes;
84 | return saveData;
85 | }
86 | )
87 | );
88 | emit('game/listSaves', savesList);
89 | return savesList;
90 | }
91 |
--------------------------------------------------------------------------------
/src/components/settings.js:
--------------------------------------------------------------------------------
1 | import { interfaces } from '../utils/interfaces';
2 | import { getConfig } from '../utils/config';
3 | import { emit } from '../utils/emitter';
4 |
5 | const handlers = {
6 | mute: (oldValue, newValue) => interfaces().sound.mute(newValue),
7 | volume: (oldValue, newValue) => interfaces().sound.setVolume(newValue)
8 | };
9 |
10 | async function load() {
11 | const { state, persistent } = interfaces();
12 | // load default values first
13 | const defaultSettings = JSON.parse(JSON.stringify(getConfig().settings)); // TODO use deep copy here
14 | // load values from save if exist
15 | let savedSettings = {};
16 | const existSavedSettings = await persistent.exists('settings');
17 | if (existSavedSettings) {
18 | savedSettings = await persistent.get('settings');
19 | }
20 | // save app settings to state
21 | const settings = { ...defaultSettings, ...savedSettings };
22 | state.setKey('settings', settings);
23 | runHandlers();
24 | emit('settings/load', settings);
25 | }
26 |
27 | async function save() {
28 | const { state, persistent } = interfaces();
29 | const appState = state.get();
30 | await persistent.set('settings', appState.settings);
31 | emit('settings/save', appState.settings);
32 | }
33 |
34 | function get(name) {
35 | const value = interfaces().state.get().settings[name];
36 | emit('settings/get', { name, value });
37 | return value;
38 | }
39 |
40 | function set(name, newValue) {
41 | const { state } = interfaces();
42 | const oldValue = state.get().settings[name];
43 | state.setSubkey('settings', name, newValue);
44 | if (handlers[name]) {
45 | handlers[name](oldValue, newValue);
46 | }
47 | emit('settings/set', { name, value: newValue });
48 | }
49 |
50 | function toggle(name) {
51 | const oldValue = get(name);
52 | set(name, !oldValue);
53 | }
54 |
55 | function defineHandler(key, handlerFn) {
56 | handlers[key] = handlerFn;
57 | }
58 |
59 | function runHandlers() {
60 | const currentSettings = interfaces().state.get().settings;
61 | Object.entries(currentSettings).forEach(([k, v]) => {
62 | if (handlers[k]) {
63 | handlers[k](null, v);
64 | }
65 | });
66 | }
67 |
68 | export default {
69 | load,
70 | save,
71 | get,
72 | set,
73 | toggle,
74 | defineHandler,
75 | runHandlers
76 | };
77 |
--------------------------------------------------------------------------------
/src/components/sound.js:
--------------------------------------------------------------------------------
1 | import { interfaces } from '../utils/interfaces';
2 | import toArray from '../utils/to-array';
3 |
4 | export function playSound(snd) {
5 | const { sound, loader } = interfaces();
6 | toArray(snd).forEach(
7 | (file) => sound.playSound(loader.getAssetPath(file))
8 | );
9 | }
10 |
11 | export function stopSound(snd) {
12 | const { sound, loader } = interfaces();
13 | toArray(snd).forEach(
14 | (file) => sound.stopSound(file ? loader.getAssetPath(file) : null)
15 | );
16 | }
17 |
18 | export function playMusic(mus) {
19 | const { sound, state, loader } = interfaces();
20 | const currentMusic = state.get().game.$currentMusic || [];
21 | toArray(mus).forEach((file) => {
22 | sound.playMusic(loader.getAssetPath(file));
23 | currentMusic.push(file);
24 | });
25 | state.setSubkey('game', '$currentMusic', currentMusic);
26 | }
27 |
28 | export function playSingleMusic(mus) {
29 | const { sound, state, loader } = interfaces();
30 | sound.stopMusic();
31 | const file = toArray(mus).pop();
32 | sound.playMusic(loader.getAssetPath(file));
33 | state.setSubkey('game', '$currentMusic', [file]);
34 | }
35 |
36 | export function stopMusic(mus) {
37 | const { sound, state, loader } = interfaces();
38 | let currentMusic = state.get().game.$currentMusic || [];
39 | toArray(mus).forEach((file) => {
40 | if (file) {
41 | sound.stopMusic(loader.getAssetPath(file));
42 | currentMusic = currentMusic.filter((m) => m !== file);
43 | } else {
44 | sound.stopMusic();
45 | currentMusic = [];
46 | }
47 | });
48 | state.setSubkey('game', '$currentMusic', currentMusic);
49 | }
50 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | import { interfaces, defineInterfaces } from './utils/interfaces';
2 | import { getConfig, setConfig } from './utils/config';
3 | import { emitter, emit } from './utils/emitter';
4 |
5 | import game from './components/game';
6 | import ink from './components/ink';
7 | import settings from './components/settings';
8 |
9 | // @atrament/core version
10 | const version = '2.0.0';
11 |
12 | /*
13 | Initialize engine:
14 | - save global config
15 | - initialize persistent storage
16 | - if persistent settings found: load settings
17 | - save settings to application state
18 | - initialize sound
19 | */
20 | async function init(InkStory, cfg) {
21 | emit('atrament/init');
22 | const { persistent } = interfaces();
23 | // save global configuration
24 | setConfig(InkStory, cfg);
25 | // initialize persistent storage
26 | persistent.init(getConfig().applicationID);
27 | // load app settings from storage
28 | await settings.load();
29 | }
30 |
31 | export default {
32 | get interfaces() {
33 | return interfaces();
34 | },
35 | defineInterfaces,
36 | init,
37 | get state() {
38 | return interfaces().state;
39 | },
40 | get store() {
41 | return interfaces().state.store();
42 | },
43 | on: (event, callback) => emitter.on(event, callback),
44 | off: (event, callback) => emitter.off(event, callback),
45 | // sub-objects
46 | game,
47 | ink,
48 | settings,
49 | version
50 | };
51 |
--------------------------------------------------------------------------------
/src/utils/config.js:
--------------------------------------------------------------------------------
1 | const atramentConfig = {
2 | settings: {
3 | volume: 0,
4 | mute: true
5 | }
6 | };
7 |
8 | export function getConfig() {
9 | return atramentConfig;
10 | }
11 |
12 | export function setConfig(InkStory, cfg = {}) {
13 | if (!InkStory) {
14 | throw new Error('atrament.init: provide ink Story constructor as a first argument!');
15 | }
16 | if (typeof InkStory !== 'function') {
17 | throw new Error('atrament.init: Story is not a constructor!');
18 | }
19 | if (!cfg.applicationID) {
20 | throw new Error('atrament.init: config.applicationID is not set!');
21 | }
22 | atramentConfig.InkStory = InkStory;
23 | Object.entries(cfg).forEach(([k, v]) => {
24 | // TODO use better deep copy here
25 | // TODO merge default config with the new config
26 | atramentConfig[k] = JSON.parse(JSON.stringify(v));
27 | });
28 | }
29 |
--------------------------------------------------------------------------------
/src/utils/emitter.js:
--------------------------------------------------------------------------------
1 | import mitt from 'mitt';
2 |
3 | export const emitter = mitt();
4 | export const emit = (...args) => emitter.emit(...args);
5 |
--------------------------------------------------------------------------------
/src/utils/hashcode.js:
--------------------------------------------------------------------------------
1 | /* eslint no-bitwise: "off" */
2 |
3 | export default function hashCode(str) {
4 | return [...str].reduce((s, c) => Math.imul(31, s) + c.charCodeAt(0) | 0, 0);
5 | }
6 |
--------------------------------------------------------------------------------
/src/utils/interfaces.js:
--------------------------------------------------------------------------------
1 | let $interfaces = {
2 | loader: null,
3 | persistent: null,
4 | sound: {
5 | init: () => {},
6 | mute: () => {},
7 | isMuted: () => {},
8 | setVolume: () => {},
9 | getVolume: () => {},
10 | playSound: () => {},
11 | playMusic: () => {},
12 | stopMusic: () => {}
13 | },
14 | state: null
15 | };
16 |
17 | export const interfaces = () => $interfaces;
18 |
19 | export function defineInterfaces(interfaceDefinitions) {
20 | $interfaces = { ...$interfaces, ...interfaceDefinitions };
21 | }
22 |
--------------------------------------------------------------------------------
/src/utils/scene-processors.js:
--------------------------------------------------------------------------------
1 | import toArray from './to-array';
2 |
3 | function $processTag(scene, tag, store) {
4 | if (!scene[store]) {
5 | scene[store] = [];
6 | }
7 | scene.content = scene.content.map((paragraph) => {
8 | if (!paragraph[store]) {
9 | paragraph[store] = [];
10 | }
11 | const processedTag = paragraph.tags?.[tag];
12 | if (processedTag) {
13 | const t = toArray(processedTag);
14 | scene[store] = [...scene[store], ...t];
15 | paragraph[store] = [...paragraph[store], ...t];
16 | }
17 | return paragraph;
18 | });
19 | }
20 |
21 | function tagDisabledChoices(scene) {
22 | scene.choices = scene.choices.map((choice) => {
23 | if (choice.tags.UNCLICKABLE || choice.tags.DISABLED || choice.tags.INACTIVE) {
24 | choice.disabled = true;
25 | }
26 | return choice;
27 | });
28 | }
29 |
30 | export default [
31 | (scene) => $processTag(scene, 'IMAGE', 'images'),
32 | (scene) => $processTag(scene, 'AUDIO', 'sounds'),
33 | (scene) => $processTag(scene, 'PLAY_SOUND', 'sounds'),
34 | (scene) => $processTag(scene, 'AUDIOLOOP', 'music'),
35 | (scene) => $processTag(scene, 'PLAY_MUSIC', 'music'),
36 | tagDisabledChoices
37 | ];
38 |
--------------------------------------------------------------------------------
/src/utils/tags.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Parses tags from ink
3 | *
4 | * @export
5 | * @param {*} tags - array of tags
6 | * @returns {{}} key-value dictionary of tags
7 | */
8 | export function parseTags(tags) {
9 | if (!tags) {
10 | return {};
11 | }
12 | const tagsObj = {};
13 | tags.forEach((item) => {
14 | let tagName;
15 | let tagValue;
16 |
17 | const content = item.match(/\s*(\w+)\s*:\s*(.+?)\s*$/);
18 | if (content) {
19 | // tag is in "key: value" format
20 | const [, key, value] = content;
21 | tagName = key;
22 | try {
23 | tagValue = JSON.parse(value); // this is JSON
24 | } catch (e) {
25 | const firstChar = value.substr(0, 1);
26 | if (firstChar === '{' || firstChar === '[') {
27 | console.warn('Malformed JSON tag, ignored.', key, value); /* eslint-disable-line */
28 | } else {
29 | tagValue = value;
30 | }
31 | }
32 | } else {
33 | tagName = item; // use tag as key name
34 | tagValue = true;
35 | }
36 |
37 | // if there are multiple tags with the same name,
38 | // store them as array
39 | if (Array.isArray(tagsObj[tagName])) {
40 | tagsObj[tagName].push(tagValue);
41 | } else if (tagsObj[tagName]) {
42 | tagsObj[tagName] = [tagsObj[tagName], tagValue];
43 | } else {
44 | tagsObj[tagName] = tagValue;
45 | }
46 | });
47 | return tagsObj;
48 | }
49 |
--------------------------------------------------------------------------------
/src/utils/to-array.js:
--------------------------------------------------------------------------------
1 | export default function toArray(v) {
2 | return Array.isArray(v) ? v : [v];
3 | }
4 |
--------------------------------------------------------------------------------