├── .eslintrc ├── .gitignore ├── LICENSE.md ├── README.md ├── atrament.config.json ├── index.html ├── jsconfig.json ├── package.json ├── resources ├── fonts │ ├── fira-sans │ │ ├── index.css │ │ ├── index.js │ │ ├── va9B4kDNxMZdWfMOD5VnLK3eQhf6TF0.woff2 │ │ ├── va9B4kDNxMZdWfMOD5VnLK3eRRf6TF0.woff2 │ │ ├── va9B4kDNxMZdWfMOD5VnLK3eRhf6.woff2 │ │ ├── va9B4kDNxMZdWfMOD5VnLK3eSBf6TF0.woff2 │ │ ├── va9B4kDNxMZdWfMOD5VnLK3eSRf6TF0.woff2 │ │ ├── va9B4kDNxMZdWfMOD5VnLK3eShf6TF0.woff2 │ │ ├── va9B4kDNxMZdWfMOD5VnLK3eSxf6TF0.woff2 │ │ ├── va9C4kDNxMZdWfMOD5VvkrjEYTLHdQ.woff2 │ │ ├── va9C4kDNxMZdWfMOD5VvkrjFYTLHdQ.woff2 │ │ ├── va9C4kDNxMZdWfMOD5VvkrjGYTLHdQ.woff2 │ │ ├── va9C4kDNxMZdWfMOD5VvkrjHYTLHdQ.woff2 │ │ ├── va9C4kDNxMZdWfMOD5VvkrjJYTI.woff2 │ │ ├── va9C4kDNxMZdWfMOD5VvkrjKYTLHdQ.woff2 │ │ ├── va9C4kDNxMZdWfMOD5VvkrjNYTLHdQ.woff2 │ │ ├── va9E4kDNxMZdWfMOD5Vvk4jLeTY.woff2 │ │ ├── va9E4kDNxMZdWfMOD5Vvl4jL.woff2 │ │ ├── va9E4kDNxMZdWfMOD5VvlIjLeTY.woff2 │ │ ├── va9E4kDNxMZdWfMOD5Vvm4jLeTY.woff2 │ │ ├── va9E4kDNxMZdWfMOD5VvmIjLeTY.woff2 │ │ ├── va9E4kDNxMZdWfMOD5VvmYjLeTY.woff2 │ │ ├── va9E4kDNxMZdWfMOD5VvmojLeTY.woff2 │ │ ├── va9f4kDNxMZdWfMOD5VvkrByRCf0VFn2lg.woff2 │ │ ├── va9f4kDNxMZdWfMOD5VvkrByRCf1VFn2lg.woff2 │ │ ├── va9f4kDNxMZdWfMOD5VvkrByRCf2VFn2lg.woff2 │ │ ├── va9f4kDNxMZdWfMOD5VvkrByRCf3VFn2lg.woff2 │ │ ├── va9f4kDNxMZdWfMOD5VvkrByRCf4VFk.woff2 │ │ ├── va9f4kDNxMZdWfMOD5VvkrByRCf7VFn2lg.woff2 │ │ └── va9f4kDNxMZdWfMOD5VvkrByRCf8VFn2lg.woff2 │ ├── lora │ │ ├── 0QIhMX1D_JOuMw_LIftL.woff2 │ │ ├── 0QIhMX1D_JOuMw_LJftLp_A.woff2 │ │ ├── 0QIhMX1D_JOuMw_LLPtLp_A.woff2 │ │ ├── 0QIhMX1D_JOuMw_LL_tLp_A.woff2 │ │ ├── 0QIhMX1D_JOuMw_LLvtLp_A.woff2 │ │ ├── 0QIvMX1D_JOuMw77I-NP.woff2 │ │ ├── 0QIvMX1D_JOuMwT7I-NP.woff2 │ │ ├── 0QIvMX1D_JOuMwX7I-NP.woff2 │ │ ├── 0QIvMX1D_JOuMwf7I-NP.woff2 │ │ ├── 0QIvMX1D_JOuMwr7Iw.woff2 │ │ ├── index.css │ │ └── index.js │ ├── merriweather │ │ ├── index.css │ │ ├── index.js │ │ ├── u-440qyriQwlOrhSvowK_l5-cSZMZ-Y.woff2 │ │ ├── u-440qyriQwlOrhSvowK_l5-ciZMZ-Y.woff2 │ │ ├── u-440qyriQwlOrhSvowK_l5-cyZMZ-Y.woff2 │ │ ├── u-440qyriQwlOrhSvowK_l5-eCZMZ-Y.woff2 │ │ ├── u-440qyriQwlOrhSvowK_l5-fCZM.woff2 │ │ ├── u-4l0qyriQwlOrhSvowK_l5-eR71Wvf1jvzRPA.woff2 │ │ ├── u-4l0qyriQwlOrhSvowK_l5-eR71Wvf2jvzRPA.woff2 │ │ ├── u-4l0qyriQwlOrhSvowK_l5-eR71Wvf3jvzRPA.woff2 │ │ ├── u-4l0qyriQwlOrhSvowK_l5-eR71Wvf4jvw.woff2 │ │ ├── u-4l0qyriQwlOrhSvowK_l5-eR71Wvf8jvzRPA.woff2 │ │ ├── u-4m0qyriQwlOrhSvowK_l5-eRZAf-LHrw.woff2 │ │ ├── u-4m0qyriQwlOrhSvowK_l5-eRZBf-LHrw.woff2 │ │ ├── u-4m0qyriQwlOrhSvowK_l5-eRZDf-LHrw.woff2 │ │ ├── u-4m0qyriQwlOrhSvowK_l5-eRZKf-LHrw.woff2 │ │ ├── u-4m0qyriQwlOrhSvowK_l5-eRZOf-I.woff2 │ │ ├── u-4n0qyriQwlOrhSvowK_l52xwNZV8f6lvg.woff2 │ │ ├── u-4n0qyriQwlOrhSvowK_l52xwNZVcf6lvg.woff2 │ │ ├── u-4n0qyriQwlOrhSvowK_l52xwNZVsf6lvg.woff2 │ │ ├── u-4n0qyriQwlOrhSvowK_l52xwNZWMf6.woff2 │ │ └── u-4n0qyriQwlOrhSvowK_l52xwNZXMf6lvg.woff2 │ └── opendyslexic │ │ ├── OpenDyslexic-Bold-Italic.woff2 │ │ ├── OpenDyslexic-Bold.woff2 │ │ ├── OpenDyslexic-Italic.woff2 │ │ ├── OpenDyslexic-Regular.woff2 │ │ ├── index.css │ │ └── index.js ├── styles │ └── custom.css └── themes │ ├── dark.json │ ├── light.json │ └── sepia.json ├── root ├── game │ ├── intercept.ink │ └── showcase.ink └── logo.png ├── src ├── app.css ├── atrament │ ├── hooks.js │ ├── init.js │ ├── load-defaults.js │ ├── markup.js │ ├── on-continue-story.js │ ├── on-game-start.js │ ├── scene-processors.js │ └── settings-handlers.js ├── components │ ├── app.jsx │ ├── markup │ │ ├── banner │ │ │ ├── index.jsx │ │ │ └── index.module.css │ │ ├── block │ │ │ └── index.jsx │ │ ├── button │ │ │ ├── index.jsx │ │ │ └── index.module.css │ │ ├── css │ │ │ └── index.jsx │ │ ├── font │ │ │ └── index.jsx │ │ ├── highlight │ │ │ └── index.jsx │ │ ├── html-fragment.jsx │ │ ├── index.js │ │ ├── info │ │ │ ├── index.jsx │ │ │ └── index.module.css │ │ ├── inline-image │ │ │ ├── index.jsx │ │ │ └── index.module.css │ │ ├── input │ │ │ ├── index.jsx │ │ │ └── index.module.css │ │ ├── link │ │ │ ├── index.jsx │ │ │ └── index.module.css │ │ ├── picture │ │ │ └── index.jsx │ │ ├── progress │ │ │ ├── index.jsx │ │ │ └── index.module.css │ │ ├── spoiler │ │ │ ├── index.jsx │ │ │ └── index.module.css │ │ ├── table │ │ │ └── index.jsx │ │ ├── url │ │ │ └── index.jsx │ │ └── video │ │ │ └── index.jsx │ ├── menu │ │ ├── index.jsx │ │ └── index.module.css │ ├── router.jsx │ ├── routes │ │ ├── about.jsx │ │ ├── game.jsx │ │ └── home.jsx │ ├── ui │ │ ├── animation-grid │ │ │ ├── index.jsx │ │ │ └── style.module.css │ │ ├── application-wrapper │ │ │ ├── index.jsx │ │ │ └── index.module.css │ │ ├── backdrop │ │ │ ├── index.jsx │ │ │ └── index.module.css │ │ ├── block │ │ │ ├── index.jsx │ │ │ └── index.module.css │ │ ├── break │ │ │ ├── index.jsx │ │ │ └── index.module.css │ │ ├── close-button │ │ │ ├── index.jsx │ │ │ └── index.module.css │ │ ├── collapse │ │ │ ├── index.jsx │ │ │ └── index.module.css │ │ ├── container-flex │ │ │ ├── index.jsx │ │ │ └── index.module.css │ │ ├── container-image │ │ │ ├── index.jsx │ │ │ └── index.module.css │ │ ├── container-modal │ │ │ ├── index.jsx │ │ │ └── index.module.css │ │ ├── container-text │ │ │ ├── index.jsx │ │ │ └── index.module.css │ │ ├── container │ │ │ ├── index.jsx │ │ │ └── index.module.css │ │ ├── error-modal │ │ │ ├── index.jsx │ │ │ └── index.module.css │ │ ├── header │ │ │ ├── index.jsx │ │ │ └── index.module.css │ │ ├── icons │ │ │ └── index.jsx │ │ ├── link-home │ │ │ ├── index.jsx │ │ │ └── index.module.css │ │ ├── link-menu │ │ │ ├── index.jsx │ │ │ └── index.module.css │ │ ├── loading │ │ │ ├── index.jsx │ │ │ └── index.module.css │ │ ├── markup │ │ │ └── index.jsx │ │ ├── menu-list-item │ │ │ ├── index.jsx │ │ │ └── index.module.css │ │ ├── modal │ │ │ ├── index.jsx │ │ │ └── index.module.css │ │ ├── table │ │ │ ├── index.jsx │ │ │ └── index.module.css │ │ ├── tabs │ │ │ ├── index.jsx │ │ │ └── index.module.css │ │ ├── text-paragraph │ │ │ ├── index.jsx │ │ │ └── index.module.css │ │ └── toggle │ │ │ ├── index.jsx │ │ │ └── index.module.css │ └── views │ │ ├── about │ │ ├── atrament-logo.png │ │ ├── iftf-logo.png │ │ ├── index.jsx │ │ └── index.module.css │ │ ├── debugger │ │ ├── functions.jsx │ │ ├── globaltags.jsx │ │ ├── goto.jsx │ │ ├── index.jsx │ │ ├── index.module.css │ │ ├── info.jsx │ │ ├── var-editor.jsx │ │ ├── var-editor.module.css │ │ ├── variables.jsx │ │ └── visits.jsx │ │ ├── home-menu │ │ ├── game-cover.jsx │ │ ├── index.jsx │ │ ├── load-game.jsx │ │ └── use-game-controls.js │ │ ├── loadgame │ │ └── index.jsx │ │ ├── overlay │ │ ├── index.jsx │ │ └── index.module.css │ │ ├── savegame │ │ └── index.jsx │ │ ├── sessions │ │ └── index.jsx │ │ ├── settings │ │ ├── index.jsx │ │ ├── index.module.css │ │ ├── settings-animation │ │ │ └── index.jsx │ │ ├── settings-font │ │ │ ├── index.jsx │ │ │ └── index.module.css │ │ ├── settings-fullscreen │ │ │ └── index.jsx │ │ ├── settings-sound │ │ │ ├── index.jsx │ │ │ └── index.module.css │ │ ├── settings-text │ │ │ ├── index.jsx │ │ │ └── index.module.css │ │ └── settings-theme │ │ │ ├── index.jsx │ │ │ └── index.module.css │ │ ├── story-error │ │ └── index.jsx │ │ ├── story │ │ ├── choice-button-group │ │ │ └── index.jsx │ │ ├── choice-button │ │ │ ├── index.jsx │ │ │ └── index.module.css │ │ ├── choices │ │ │ └── index.jsx │ │ ├── click-to-continue │ │ │ ├── index.jsx │ │ │ └── index.module.css │ │ ├── container-choices │ │ │ ├── index.jsx │ │ │ └── index.module.css │ │ ├── container-scenes │ │ │ ├── index.jsx │ │ │ └── index.module.css │ │ ├── index.jsx │ │ ├── scene-paragraph │ │ │ ├── index.jsx │ │ │ └── index.module.css │ │ └── scene │ │ │ ├── index.jsx │ │ │ └── index.module.css │ │ └── toolbar │ │ ├── index.jsx │ │ └── index.module.css ├── constants.js ├── context.js ├── fonts.js ├── i18n.json ├── index.jsx ├── themes.js └── utils │ ├── array-shuffle.js │ ├── get-tag-attributes.js │ ├── mute-when-inactive.js │ ├── neutralino-out-of-bounds-fix.js │ ├── page-background.js │ └── preload-images.js ├── tools ├── ink-compile.cjs ├── install-inklecate.cjs ├── neutralino-postbuild.mjs └── neutralino-prepare.cjs ├── vite.config.js └── vite ├── ink-compiler-plugin.js ├── pwa-assets.config.js ├── pwa-config.js └── remove-ink-files-plugin.js /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "preact" 4 | ], 5 | "parser": "@babel/eslint-parser", 6 | "parserOptions": { 7 | "requireConfigFile": false 8 | }, 9 | "rules": { 10 | "linebreak-style": ["error", "unix"], 11 | "brace-style": ["error", "1tbs"], 12 | "indent": ["error", 2], 13 | "react/jsx-indent-props": ["error", 2], 14 | "react/no-danger": [0] 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | package-lock.json 11 | node_modules 12 | build 13 | build-ssr 14 | *.local 15 | 16 | # compiled ink 17 | root/**/*.js 18 | root/**/*.json 19 | 20 | # inklecate 21 | tools/inklecate* 22 | 23 | # neutralino 24 | tools/neutralino/* 25 | 26 | # Editor directories and files 27 | .vscode/* 28 | !.vscode/extensions.json 29 | .idea 30 | .DS_Store 31 | *.suo 32 | *.ntvs* 33 | *.njsproj 34 | *.sln 35 | *.sw? 36 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2023 Serhii "techniX" Mozhaiskyi 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /atrament.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Atrament Web UI demo", 3 | "short_name": "atrament-web-ui", 4 | "description": "Atrament Web UI - shell for Ink games", 5 | "theme": "light", 6 | "font": "System", 7 | "language": "en", 8 | "locale": "en-US", 9 | "game": { 10 | "path": "game", 11 | "source": "intercept.ink" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | <%= title %> 6 | 7 | 8 | 9 | 10 | 11 | <%- neutralino %> 12 | 13 | 14 |
15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "module": "ESNext", 5 | "moduleResolution": "bundler", 6 | "noEmit": true, 7 | "allowJs": true, 8 | "checkJs": true, 9 | 10 | /* Preact Config */ 11 | "jsx": "react-jsx", 12 | "jsxImportSource": "preact", 13 | "skipLibCheck": true, 14 | "paths": { 15 | "react": ["./node_modules/preact/compat/"], 16 | "react-dom": ["./node_modules/preact/compat/"], 17 | "src/*": ["./src/*"], 18 | }, 19 | 20 | // "baseUrl": "." 21 | }, 22 | "include": ["node_modules/vite/client.d.ts", "**/*"] 23 | } 24 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@atrament/web-ui", 3 | "version": "2.2.0", 4 | "license": "MIT", 5 | "type": "module", 6 | "scripts": { 7 | "start": "vite", 8 | "build": "vite build", 9 | "build-web": "vite build", 10 | "build-singlefile": "vite build --mode singlefile", 11 | "build-standalone": "vite build --mode standalone && node tools/neutralino-prepare.cjs && npm run neutralino", 12 | "preview": "vite preview", 13 | "lint": "eslint --ext .jsx --ext js src", 14 | "compile-ink-script": "node tools/ink-compile.cjs", 15 | "install-inklecate": "node tools/install-inklecate.cjs", 16 | "neutralino": "cd build/.tmp_neutralino && neu build && node-rmrf ../standalone && move-file dist ../standalone && cd .. && node-rmrf ./.tmp_neutralino && cd ./standalone && node ../../tools/neutralino-postbuild.mjs" 17 | }, 18 | "dependencies": { 19 | "@atrament/web": "2.1.1", 20 | "@eo-locale/preact": "1.7.2", 21 | "@nanostores/preact": "0.5.2", 22 | "history": "5.3.0", 23 | "inkjs": "2.3.2", 24 | "normalize.css": "8.0.1", 25 | "preact": "10.26.5", 26 | "preact-router": "4.1.2", 27 | "seamless-scroll-polyfill": "2.3.4" 28 | }, 29 | "devDependencies": { 30 | "@neutralinojs/neu": "11.4.0", 31 | "@preact/preset-vite": "2.10.1", 32 | "@terascope/fetch-github-release": "2.1.0", 33 | "@vite-pwa/assets-generator": "1.0.0", 34 | "eslint": "8.57.0", 35 | "eslint-config-preact": "1.5.0", 36 | "jest": "29.7.0", 37 | "move-file-cli": "3.0.0", 38 | "node-rmrf": "1.2.0", 39 | "recursive-copy": "2.0.14", 40 | "vite": "6.2.6", 41 | "vite-plugin-clean-build": "1.4.1", 42 | "vite-plugin-html": "3.2.2", 43 | "vite-plugin-pwa": "1.0.0", 44 | "vite-plugin-singlefile": "2.2.0", 45 | "vite-plugin-zip-pack": "1.2.4", 46 | "zip-a-folder": "^3.1.9" 47 | }, 48 | "eslintConfig": { 49 | "extends": "preact" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /resources/fonts/fira-sans/index.js: -------------------------------------------------------------------------------- 1 | import('./index.css'); 2 | 3 | export default { 4 | name: 'Fira Sans', 5 | fallback: 'sans-serif', 6 | }; 7 | -------------------------------------------------------------------------------- /resources/fonts/fira-sans/va9B4kDNxMZdWfMOD5VnLK3eQhf6TF0.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/technix/atrament-web-ui/6086816b0074b3c022201d801b9b20feafbf45f8/resources/fonts/fira-sans/va9B4kDNxMZdWfMOD5VnLK3eQhf6TF0.woff2 -------------------------------------------------------------------------------- /resources/fonts/fira-sans/va9B4kDNxMZdWfMOD5VnLK3eRRf6TF0.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/technix/atrament-web-ui/6086816b0074b3c022201d801b9b20feafbf45f8/resources/fonts/fira-sans/va9B4kDNxMZdWfMOD5VnLK3eRRf6TF0.woff2 -------------------------------------------------------------------------------- /resources/fonts/fira-sans/va9B4kDNxMZdWfMOD5VnLK3eRhf6.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/technix/atrament-web-ui/6086816b0074b3c022201d801b9b20feafbf45f8/resources/fonts/fira-sans/va9B4kDNxMZdWfMOD5VnLK3eRhf6.woff2 -------------------------------------------------------------------------------- /resources/fonts/fira-sans/va9B4kDNxMZdWfMOD5VnLK3eSBf6TF0.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/technix/atrament-web-ui/6086816b0074b3c022201d801b9b20feafbf45f8/resources/fonts/fira-sans/va9B4kDNxMZdWfMOD5VnLK3eSBf6TF0.woff2 -------------------------------------------------------------------------------- /resources/fonts/fira-sans/va9B4kDNxMZdWfMOD5VnLK3eSRf6TF0.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/technix/atrament-web-ui/6086816b0074b3c022201d801b9b20feafbf45f8/resources/fonts/fira-sans/va9B4kDNxMZdWfMOD5VnLK3eSRf6TF0.woff2 -------------------------------------------------------------------------------- /resources/fonts/fira-sans/va9B4kDNxMZdWfMOD5VnLK3eShf6TF0.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/technix/atrament-web-ui/6086816b0074b3c022201d801b9b20feafbf45f8/resources/fonts/fira-sans/va9B4kDNxMZdWfMOD5VnLK3eShf6TF0.woff2 -------------------------------------------------------------------------------- /resources/fonts/fira-sans/va9B4kDNxMZdWfMOD5VnLK3eSxf6TF0.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/technix/atrament-web-ui/6086816b0074b3c022201d801b9b20feafbf45f8/resources/fonts/fira-sans/va9B4kDNxMZdWfMOD5VnLK3eSxf6TF0.woff2 -------------------------------------------------------------------------------- /resources/fonts/fira-sans/va9C4kDNxMZdWfMOD5VvkrjEYTLHdQ.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/technix/atrament-web-ui/6086816b0074b3c022201d801b9b20feafbf45f8/resources/fonts/fira-sans/va9C4kDNxMZdWfMOD5VvkrjEYTLHdQ.woff2 -------------------------------------------------------------------------------- /resources/fonts/fira-sans/va9C4kDNxMZdWfMOD5VvkrjFYTLHdQ.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/technix/atrament-web-ui/6086816b0074b3c022201d801b9b20feafbf45f8/resources/fonts/fira-sans/va9C4kDNxMZdWfMOD5VvkrjFYTLHdQ.woff2 -------------------------------------------------------------------------------- /resources/fonts/fira-sans/va9C4kDNxMZdWfMOD5VvkrjGYTLHdQ.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/technix/atrament-web-ui/6086816b0074b3c022201d801b9b20feafbf45f8/resources/fonts/fira-sans/va9C4kDNxMZdWfMOD5VvkrjGYTLHdQ.woff2 -------------------------------------------------------------------------------- /resources/fonts/fira-sans/va9C4kDNxMZdWfMOD5VvkrjHYTLHdQ.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/technix/atrament-web-ui/6086816b0074b3c022201d801b9b20feafbf45f8/resources/fonts/fira-sans/va9C4kDNxMZdWfMOD5VvkrjHYTLHdQ.woff2 -------------------------------------------------------------------------------- /resources/fonts/fira-sans/va9C4kDNxMZdWfMOD5VvkrjJYTI.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/technix/atrament-web-ui/6086816b0074b3c022201d801b9b20feafbf45f8/resources/fonts/fira-sans/va9C4kDNxMZdWfMOD5VvkrjJYTI.woff2 -------------------------------------------------------------------------------- /resources/fonts/fira-sans/va9C4kDNxMZdWfMOD5VvkrjKYTLHdQ.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/technix/atrament-web-ui/6086816b0074b3c022201d801b9b20feafbf45f8/resources/fonts/fira-sans/va9C4kDNxMZdWfMOD5VvkrjKYTLHdQ.woff2 -------------------------------------------------------------------------------- /resources/fonts/fira-sans/va9C4kDNxMZdWfMOD5VvkrjNYTLHdQ.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/technix/atrament-web-ui/6086816b0074b3c022201d801b9b20feafbf45f8/resources/fonts/fira-sans/va9C4kDNxMZdWfMOD5VvkrjNYTLHdQ.woff2 -------------------------------------------------------------------------------- /resources/fonts/fira-sans/va9E4kDNxMZdWfMOD5Vvk4jLeTY.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/technix/atrament-web-ui/6086816b0074b3c022201d801b9b20feafbf45f8/resources/fonts/fira-sans/va9E4kDNxMZdWfMOD5Vvk4jLeTY.woff2 -------------------------------------------------------------------------------- /resources/fonts/fira-sans/va9E4kDNxMZdWfMOD5Vvl4jL.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/technix/atrament-web-ui/6086816b0074b3c022201d801b9b20feafbf45f8/resources/fonts/fira-sans/va9E4kDNxMZdWfMOD5Vvl4jL.woff2 -------------------------------------------------------------------------------- /resources/fonts/fira-sans/va9E4kDNxMZdWfMOD5VvlIjLeTY.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/technix/atrament-web-ui/6086816b0074b3c022201d801b9b20feafbf45f8/resources/fonts/fira-sans/va9E4kDNxMZdWfMOD5VvlIjLeTY.woff2 -------------------------------------------------------------------------------- /resources/fonts/fira-sans/va9E4kDNxMZdWfMOD5Vvm4jLeTY.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/technix/atrament-web-ui/6086816b0074b3c022201d801b9b20feafbf45f8/resources/fonts/fira-sans/va9E4kDNxMZdWfMOD5Vvm4jLeTY.woff2 -------------------------------------------------------------------------------- /resources/fonts/fira-sans/va9E4kDNxMZdWfMOD5VvmIjLeTY.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/technix/atrament-web-ui/6086816b0074b3c022201d801b9b20feafbf45f8/resources/fonts/fira-sans/va9E4kDNxMZdWfMOD5VvmIjLeTY.woff2 -------------------------------------------------------------------------------- /resources/fonts/fira-sans/va9E4kDNxMZdWfMOD5VvmYjLeTY.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/technix/atrament-web-ui/6086816b0074b3c022201d801b9b20feafbf45f8/resources/fonts/fira-sans/va9E4kDNxMZdWfMOD5VvmYjLeTY.woff2 -------------------------------------------------------------------------------- /resources/fonts/fira-sans/va9E4kDNxMZdWfMOD5VvmojLeTY.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/technix/atrament-web-ui/6086816b0074b3c022201d801b9b20feafbf45f8/resources/fonts/fira-sans/va9E4kDNxMZdWfMOD5VvmojLeTY.woff2 -------------------------------------------------------------------------------- /resources/fonts/fira-sans/va9f4kDNxMZdWfMOD5VvkrByRCf0VFn2lg.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/technix/atrament-web-ui/6086816b0074b3c022201d801b9b20feafbf45f8/resources/fonts/fira-sans/va9f4kDNxMZdWfMOD5VvkrByRCf0VFn2lg.woff2 -------------------------------------------------------------------------------- /resources/fonts/fira-sans/va9f4kDNxMZdWfMOD5VvkrByRCf1VFn2lg.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/technix/atrament-web-ui/6086816b0074b3c022201d801b9b20feafbf45f8/resources/fonts/fira-sans/va9f4kDNxMZdWfMOD5VvkrByRCf1VFn2lg.woff2 -------------------------------------------------------------------------------- /resources/fonts/fira-sans/va9f4kDNxMZdWfMOD5VvkrByRCf2VFn2lg.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/technix/atrament-web-ui/6086816b0074b3c022201d801b9b20feafbf45f8/resources/fonts/fira-sans/va9f4kDNxMZdWfMOD5VvkrByRCf2VFn2lg.woff2 -------------------------------------------------------------------------------- /resources/fonts/fira-sans/va9f4kDNxMZdWfMOD5VvkrByRCf3VFn2lg.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/technix/atrament-web-ui/6086816b0074b3c022201d801b9b20feafbf45f8/resources/fonts/fira-sans/va9f4kDNxMZdWfMOD5VvkrByRCf3VFn2lg.woff2 -------------------------------------------------------------------------------- /resources/fonts/fira-sans/va9f4kDNxMZdWfMOD5VvkrByRCf4VFk.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/technix/atrament-web-ui/6086816b0074b3c022201d801b9b20feafbf45f8/resources/fonts/fira-sans/va9f4kDNxMZdWfMOD5VvkrByRCf4VFk.woff2 -------------------------------------------------------------------------------- /resources/fonts/fira-sans/va9f4kDNxMZdWfMOD5VvkrByRCf7VFn2lg.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/technix/atrament-web-ui/6086816b0074b3c022201d801b9b20feafbf45f8/resources/fonts/fira-sans/va9f4kDNxMZdWfMOD5VvkrByRCf7VFn2lg.woff2 -------------------------------------------------------------------------------- /resources/fonts/fira-sans/va9f4kDNxMZdWfMOD5VvkrByRCf8VFn2lg.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/technix/atrament-web-ui/6086816b0074b3c022201d801b9b20feafbf45f8/resources/fonts/fira-sans/va9f4kDNxMZdWfMOD5VvkrByRCf8VFn2lg.woff2 -------------------------------------------------------------------------------- /resources/fonts/lora/0QIhMX1D_JOuMw_LIftL.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/technix/atrament-web-ui/6086816b0074b3c022201d801b9b20feafbf45f8/resources/fonts/lora/0QIhMX1D_JOuMw_LIftL.woff2 -------------------------------------------------------------------------------- /resources/fonts/lora/0QIhMX1D_JOuMw_LJftLp_A.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/technix/atrament-web-ui/6086816b0074b3c022201d801b9b20feafbf45f8/resources/fonts/lora/0QIhMX1D_JOuMw_LJftLp_A.woff2 -------------------------------------------------------------------------------- /resources/fonts/lora/0QIhMX1D_JOuMw_LLPtLp_A.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/technix/atrament-web-ui/6086816b0074b3c022201d801b9b20feafbf45f8/resources/fonts/lora/0QIhMX1D_JOuMw_LLPtLp_A.woff2 -------------------------------------------------------------------------------- /resources/fonts/lora/0QIhMX1D_JOuMw_LL_tLp_A.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/technix/atrament-web-ui/6086816b0074b3c022201d801b9b20feafbf45f8/resources/fonts/lora/0QIhMX1D_JOuMw_LL_tLp_A.woff2 -------------------------------------------------------------------------------- /resources/fonts/lora/0QIhMX1D_JOuMw_LLvtLp_A.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/technix/atrament-web-ui/6086816b0074b3c022201d801b9b20feafbf45f8/resources/fonts/lora/0QIhMX1D_JOuMw_LLvtLp_A.woff2 -------------------------------------------------------------------------------- /resources/fonts/lora/0QIvMX1D_JOuMw77I-NP.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/technix/atrament-web-ui/6086816b0074b3c022201d801b9b20feafbf45f8/resources/fonts/lora/0QIvMX1D_JOuMw77I-NP.woff2 -------------------------------------------------------------------------------- /resources/fonts/lora/0QIvMX1D_JOuMwT7I-NP.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/technix/atrament-web-ui/6086816b0074b3c022201d801b9b20feafbf45f8/resources/fonts/lora/0QIvMX1D_JOuMwT7I-NP.woff2 -------------------------------------------------------------------------------- /resources/fonts/lora/0QIvMX1D_JOuMwX7I-NP.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/technix/atrament-web-ui/6086816b0074b3c022201d801b9b20feafbf45f8/resources/fonts/lora/0QIvMX1D_JOuMwX7I-NP.woff2 -------------------------------------------------------------------------------- /resources/fonts/lora/0QIvMX1D_JOuMwf7I-NP.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/technix/atrament-web-ui/6086816b0074b3c022201d801b9b20feafbf45f8/resources/fonts/lora/0QIvMX1D_JOuMwf7I-NP.woff2 -------------------------------------------------------------------------------- /resources/fonts/lora/0QIvMX1D_JOuMwr7Iw.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/technix/atrament-web-ui/6086816b0074b3c022201d801b9b20feafbf45f8/resources/fonts/lora/0QIvMX1D_JOuMwr7Iw.woff2 -------------------------------------------------------------------------------- /resources/fonts/lora/index.js: -------------------------------------------------------------------------------- 1 | import('./index.css'); 2 | 3 | export default { 4 | name: 'Lora', 5 | fallback: 'serif', 6 | }; 7 | -------------------------------------------------------------------------------- /resources/fonts/merriweather/index.js: -------------------------------------------------------------------------------- 1 | import('./index.css'); 2 | 3 | export default { 4 | name: 'Merriweather', 5 | fallback: 'serif', 6 | }; 7 | -------------------------------------------------------------------------------- /resources/fonts/merriweather/u-440qyriQwlOrhSvowK_l5-cSZMZ-Y.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/technix/atrament-web-ui/6086816b0074b3c022201d801b9b20feafbf45f8/resources/fonts/merriweather/u-440qyriQwlOrhSvowK_l5-cSZMZ-Y.woff2 -------------------------------------------------------------------------------- /resources/fonts/merriweather/u-440qyriQwlOrhSvowK_l5-ciZMZ-Y.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/technix/atrament-web-ui/6086816b0074b3c022201d801b9b20feafbf45f8/resources/fonts/merriweather/u-440qyriQwlOrhSvowK_l5-ciZMZ-Y.woff2 -------------------------------------------------------------------------------- /resources/fonts/merriweather/u-440qyriQwlOrhSvowK_l5-cyZMZ-Y.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/technix/atrament-web-ui/6086816b0074b3c022201d801b9b20feafbf45f8/resources/fonts/merriweather/u-440qyriQwlOrhSvowK_l5-cyZMZ-Y.woff2 -------------------------------------------------------------------------------- /resources/fonts/merriweather/u-440qyriQwlOrhSvowK_l5-eCZMZ-Y.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/technix/atrament-web-ui/6086816b0074b3c022201d801b9b20feafbf45f8/resources/fonts/merriweather/u-440qyriQwlOrhSvowK_l5-eCZMZ-Y.woff2 -------------------------------------------------------------------------------- /resources/fonts/merriweather/u-440qyriQwlOrhSvowK_l5-fCZM.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/technix/atrament-web-ui/6086816b0074b3c022201d801b9b20feafbf45f8/resources/fonts/merriweather/u-440qyriQwlOrhSvowK_l5-fCZM.woff2 -------------------------------------------------------------------------------- /resources/fonts/merriweather/u-4l0qyriQwlOrhSvowK_l5-eR71Wvf1jvzRPA.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/technix/atrament-web-ui/6086816b0074b3c022201d801b9b20feafbf45f8/resources/fonts/merriweather/u-4l0qyriQwlOrhSvowK_l5-eR71Wvf1jvzRPA.woff2 -------------------------------------------------------------------------------- /resources/fonts/merriweather/u-4l0qyriQwlOrhSvowK_l5-eR71Wvf2jvzRPA.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/technix/atrament-web-ui/6086816b0074b3c022201d801b9b20feafbf45f8/resources/fonts/merriweather/u-4l0qyriQwlOrhSvowK_l5-eR71Wvf2jvzRPA.woff2 -------------------------------------------------------------------------------- /resources/fonts/merriweather/u-4l0qyriQwlOrhSvowK_l5-eR71Wvf3jvzRPA.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/technix/atrament-web-ui/6086816b0074b3c022201d801b9b20feafbf45f8/resources/fonts/merriweather/u-4l0qyriQwlOrhSvowK_l5-eR71Wvf3jvzRPA.woff2 -------------------------------------------------------------------------------- /resources/fonts/merriweather/u-4l0qyriQwlOrhSvowK_l5-eR71Wvf4jvw.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/technix/atrament-web-ui/6086816b0074b3c022201d801b9b20feafbf45f8/resources/fonts/merriweather/u-4l0qyriQwlOrhSvowK_l5-eR71Wvf4jvw.woff2 -------------------------------------------------------------------------------- /resources/fonts/merriweather/u-4l0qyriQwlOrhSvowK_l5-eR71Wvf8jvzRPA.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/technix/atrament-web-ui/6086816b0074b3c022201d801b9b20feafbf45f8/resources/fonts/merriweather/u-4l0qyriQwlOrhSvowK_l5-eR71Wvf8jvzRPA.woff2 -------------------------------------------------------------------------------- /resources/fonts/merriweather/u-4m0qyriQwlOrhSvowK_l5-eRZAf-LHrw.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/technix/atrament-web-ui/6086816b0074b3c022201d801b9b20feafbf45f8/resources/fonts/merriweather/u-4m0qyriQwlOrhSvowK_l5-eRZAf-LHrw.woff2 -------------------------------------------------------------------------------- /resources/fonts/merriweather/u-4m0qyriQwlOrhSvowK_l5-eRZBf-LHrw.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/technix/atrament-web-ui/6086816b0074b3c022201d801b9b20feafbf45f8/resources/fonts/merriweather/u-4m0qyriQwlOrhSvowK_l5-eRZBf-LHrw.woff2 -------------------------------------------------------------------------------- /resources/fonts/merriweather/u-4m0qyriQwlOrhSvowK_l5-eRZDf-LHrw.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/technix/atrament-web-ui/6086816b0074b3c022201d801b9b20feafbf45f8/resources/fonts/merriweather/u-4m0qyriQwlOrhSvowK_l5-eRZDf-LHrw.woff2 -------------------------------------------------------------------------------- /resources/fonts/merriweather/u-4m0qyriQwlOrhSvowK_l5-eRZKf-LHrw.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/technix/atrament-web-ui/6086816b0074b3c022201d801b9b20feafbf45f8/resources/fonts/merriweather/u-4m0qyriQwlOrhSvowK_l5-eRZKf-LHrw.woff2 -------------------------------------------------------------------------------- /resources/fonts/merriweather/u-4m0qyriQwlOrhSvowK_l5-eRZOf-I.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/technix/atrament-web-ui/6086816b0074b3c022201d801b9b20feafbf45f8/resources/fonts/merriweather/u-4m0qyriQwlOrhSvowK_l5-eRZOf-I.woff2 -------------------------------------------------------------------------------- /resources/fonts/merriweather/u-4n0qyriQwlOrhSvowK_l52xwNZV8f6lvg.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/technix/atrament-web-ui/6086816b0074b3c022201d801b9b20feafbf45f8/resources/fonts/merriweather/u-4n0qyriQwlOrhSvowK_l52xwNZV8f6lvg.woff2 -------------------------------------------------------------------------------- /resources/fonts/merriweather/u-4n0qyriQwlOrhSvowK_l52xwNZVcf6lvg.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/technix/atrament-web-ui/6086816b0074b3c022201d801b9b20feafbf45f8/resources/fonts/merriweather/u-4n0qyriQwlOrhSvowK_l52xwNZVcf6lvg.woff2 -------------------------------------------------------------------------------- /resources/fonts/merriweather/u-4n0qyriQwlOrhSvowK_l52xwNZVsf6lvg.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/technix/atrament-web-ui/6086816b0074b3c022201d801b9b20feafbf45f8/resources/fonts/merriweather/u-4n0qyriQwlOrhSvowK_l52xwNZVsf6lvg.woff2 -------------------------------------------------------------------------------- /resources/fonts/merriweather/u-4n0qyriQwlOrhSvowK_l52xwNZWMf6.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/technix/atrament-web-ui/6086816b0074b3c022201d801b9b20feafbf45f8/resources/fonts/merriweather/u-4n0qyriQwlOrhSvowK_l52xwNZWMf6.woff2 -------------------------------------------------------------------------------- /resources/fonts/merriweather/u-4n0qyriQwlOrhSvowK_l52xwNZXMf6lvg.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/technix/atrament-web-ui/6086816b0074b3c022201d801b9b20feafbf45f8/resources/fonts/merriweather/u-4n0qyriQwlOrhSvowK_l52xwNZXMf6lvg.woff2 -------------------------------------------------------------------------------- /resources/fonts/opendyslexic/OpenDyslexic-Bold-Italic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/technix/atrament-web-ui/6086816b0074b3c022201d801b9b20feafbf45f8/resources/fonts/opendyslexic/OpenDyslexic-Bold-Italic.woff2 -------------------------------------------------------------------------------- /resources/fonts/opendyslexic/OpenDyslexic-Bold.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/technix/atrament-web-ui/6086816b0074b3c022201d801b9b20feafbf45f8/resources/fonts/opendyslexic/OpenDyslexic-Bold.woff2 -------------------------------------------------------------------------------- /resources/fonts/opendyslexic/OpenDyslexic-Italic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/technix/atrament-web-ui/6086816b0074b3c022201d801b9b20feafbf45f8/resources/fonts/opendyslexic/OpenDyslexic-Italic.woff2 -------------------------------------------------------------------------------- /resources/fonts/opendyslexic/OpenDyslexic-Regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/technix/atrament-web-ui/6086816b0074b3c022201d801b9b20feafbf45f8/resources/fonts/opendyslexic/OpenDyslexic-Regular.woff2 -------------------------------------------------------------------------------- /resources/fonts/opendyslexic/index.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: 'OpenDyslexic'; 3 | font-display: swap; 4 | font-style: normal; 5 | font-weight: 400; 6 | src: url(OpenDyslexic-Regular.woff2) format('woff2'); 7 | } 8 | 9 | @font-face { 10 | font-family: 'OpenDyslexic'; 11 | font-display: swap; 12 | font-style: italic; 13 | font-weight: 400; 14 | src: url(OpenDyslexic-Italic.woff2) format('woff2'); 15 | } 16 | 17 | @font-face { 18 | font-family: 'OpenDyslexic'; 19 | font-display: swap; 20 | font-style: normal; 21 | font-weight: 700; 22 | src: url(OpenDyslexic-Bold.woff2) format('woff2'); 23 | } 24 | 25 | @font-face { 26 | font-family: 'OpenDyslexic'; 27 | font-display: swap; 28 | font-style: italic; 29 | font-weight: 700; 30 | src: url(OpenDyslexic-Bold-Italic.woff2) format('woff2'); 31 | } 32 | -------------------------------------------------------------------------------- /resources/fonts/opendyslexic/index.js: -------------------------------------------------------------------------------- 1 | import('./index.css'); 2 | 3 | export default { 4 | name: 'OpenDyslexic', 5 | fallback: 'serif', 6 | }; 7 | -------------------------------------------------------------------------------- /resources/styles/custom.css: -------------------------------------------------------------------------------- 1 | /* Write custom CSS classes and rules here */ 2 | 3 | /* 4 | Available classes: 5 | .atrament-ui-app Application wrapper div 6 | .atrament-backdrop Modal backdrop 7 | .atrament-block Block 8 | .atrament-container Application container div 9 | .atrament-flex-container Application flex container div 10 | .atrament-image-container Image container div 11 | .atrament-image Image inside of a container 12 | .atrament-text-container Text container div 13 | .atrament-header Header 14 | .atrament-loading Loading animation div 15 | .atrament-modal Modal div 16 | .atrament-overlay Overlay div 17 | .atrament-toolbar Toolbar div 18 | .atrament-container-choices Container for choices list 19 | .atrament-container-scene Scene container div 20 | .atrament-scene Scene content div 21 | */ 22 | -------------------------------------------------------------------------------- /resources/themes/dark.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dark", 3 | "theme": { 4 | "bg-color": "#4c3b4d", 5 | "fg-color": "#FCFCFC", 6 | "shade-color": "rgba(255, 255, 255, 0.1)", 7 | "font-color": "#EEEEEE", 8 | "accent-bg-color": "#EEEEEE", 9 | "accent-fg-color": "#F7567C", 10 | "border-radius": "0.5rem", 11 | "border-radius-inline": "0.25rem" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /resources/themes/light.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "light", 3 | "theme": { 4 | "bg-color": "#FCFCFC", 5 | "fg-color": "#5D576B", 6 | "shade-color": "rgba(0, 0, 0, 0.1)", 7 | "font-color": "#333333", 8 | "accent-bg-color": "#FCFCFC", 9 | "accent-fg-color": "#F7567C", 10 | "border-radius": "0.5rem", 11 | "border-radius-inline": "0.25rem" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /resources/themes/sepia.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sepia", 3 | "theme": { 4 | "bg-color": "#ffeedb", 5 | "fg-color": "#4c3b4d", 6 | "shade-color": "rgba(0, 0, 0, 0.1)", 7 | "font-color": "#333333", 8 | "accent-bg-color": "#ffeedb", 9 | "accent-fg-color": "#a53850", 10 | "border-radius": "0.5rem", 11 | "border-radius-inline": "0.25rem" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /root/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/technix/atrament-web-ui/6086816b0074b3c022201d801b9b20feafbf45f8/root/logo.png -------------------------------------------------------------------------------- /src/app.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --bg-color: #FCFCFC; 3 | --fg-color: #5D576B; 4 | --shade-color: rgba(0, 0, 0, 0.1); 5 | --font-color: #333333; 6 | --accent-bg-color: #FCFCFC; 7 | --accent-fg-color: #F7567C; 8 | --border-radius: 0.5rem; 9 | --border-radius-inline: 0.25rem; 10 | --font-interface: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; 11 | --game-font-size: 100%; 12 | } 13 | 14 | html { 15 | scroll-behavior: smooth; 16 | } 17 | 18 | body { 19 | margin: 0px; 20 | padding: 0px; 21 | background-color: var(--bg-color); 22 | color: var(--font-color); 23 | font-size: 1.2em; 24 | font-family: var(--font-interface); 25 | } 26 | 27 | * { 28 | box-sizing: border-box; 29 | } 30 | 31 | mark { 32 | background-color: inherit; 33 | color: var(--accent-fg-color); 34 | } 35 | 36 | blockquote { 37 | border-left: 0.2em solid var(--accent-fg-color); 38 | margin-left: 0; 39 | padding-left: 1em; 40 | } 41 | 42 | h1, h2, h3, h4, h4, h6 { 43 | line-height: normal; 44 | } 45 | 46 | @keyframes appear { 47 | 0% { 48 | opacity: 0; 49 | } 50 | 100% { 51 | opacity: 1; 52 | } 53 | } 54 | 55 | a, a:visited { 56 | color: var(--accent-fg-color); 57 | } 58 | 59 | .animation_appear { 60 | animation-name: appear; 61 | animation-duration: var(--animation-disabled, 0.5s); 62 | animation-timing-function: ease-in; 63 | animation-delay: 0s; 64 | animation-iteration-count: 1; 65 | animation-fill-mode: forwards; 66 | } 67 | 68 | /* scrollbars */ 69 | 70 | *::-webkit-scrollbar { 71 | width: 16px; 72 | } 73 | 74 | *::-webkit-scrollbar-track { 75 | border-radius: 8px; 76 | } 77 | 78 | *::-webkit-scrollbar-thumb { 79 | height: 56px; 80 | border-radius: 8px; 81 | border: 4px solid transparent; 82 | background-clip: content-box; 83 | background-color: #888; 84 | } 85 | 86 | *::-webkit-scrollbar-thumb:hover { 87 | background-color: #555; 88 | } 89 | -------------------------------------------------------------------------------- /src/atrament/hooks.js: -------------------------------------------------------------------------------- 1 | import { useContext, useCallback } from 'preact/hooks'; 2 | import { useStore } from '@nanostores/preact'; 3 | import { AtramentContext } from 'src/context'; 4 | import { OVERLAY_STORE_KEY } from 'src/constants'; 5 | 6 | export const useAtrament = () => { 7 | const atrament = useContext(AtramentContext); 8 | 9 | const inkErrorWrapper = useCallback((fn) => { 10 | try { 11 | return fn(); 12 | } catch (e) { 13 | if (atrament.ink.story().onError) { 14 | atrament.ink.story().onError(e.toString()); 15 | } 16 | return null; 17 | } 18 | }, [ atrament ]); 19 | 20 | const getAssetPath = useCallback( 21 | (file) => file ? atrament.game.getAssetPath(file) : null, 22 | [ atrament ] 23 | ); 24 | 25 | const updateSettings = useCallback( 26 | (name, value) => { 27 | atrament.settings.set(name, value); 28 | atrament.settings.save(); 29 | }, 30 | [ atrament ] 31 | ); 32 | 33 | const setStateSubkey = useCallback( 34 | (...args) => atrament.state.setSubkey(...args), 35 | [ atrament ] 36 | ); 37 | 38 | const evaluateInkFunction = useCallback( 39 | (fn, args=[]) => { 40 | let result = {}; 41 | try { 42 | result = atrament.ink.evaluateFunction(fn, args, true); 43 | } catch (e) { 44 | if (atrament.ink.story().onError) { 45 | atrament.ink.story().onError(e.toString()); 46 | } 47 | result.error = e.toString(); 48 | } 49 | return result; 50 | }, 51 | [ atrament ] 52 | ); 53 | 54 | const setInkVariable = useCallback( 55 | (...args) => inkErrorWrapper(() => atrament.ink.setVariable(...args)), 56 | [ atrament, inkErrorWrapper ] 57 | ); 58 | 59 | const getInkVariable = useCallback( 60 | (...args) => inkErrorWrapper(() => atrament.ink.getVariable(...args)), 61 | [ atrament, inkErrorWrapper ] 62 | ); 63 | 64 | const makeChoice = useCallback( 65 | (...args) => inkErrorWrapper(() => atrament.game.makeChoice(...args)), 66 | [ atrament, inkErrorWrapper ] 67 | ); 68 | 69 | const continueStory = useCallback( 70 | (...args) => inkErrorWrapper(() => atrament.game.continueStory(...args)), 71 | [ atrament, inkErrorWrapper ] 72 | ); 73 | 74 | const resetBackground = useCallback(() => { 75 | atrament.state.setSubkey('game', 'background', null); 76 | atrament.state.setSubkey('game', 'background_page', null); 77 | }, [ atrament ]); 78 | 79 | return { 80 | atrament, 81 | canResume: atrament.game.canResume, 82 | gameStart: atrament.game.start, 83 | gameResume: atrament.game.resume, 84 | makeChoice, 85 | continueStory, 86 | getAssetPath, 87 | updateSettings, 88 | setStateSubkey, 89 | evaluateInkFunction, 90 | setInkVariable, 91 | getInkVariable, 92 | resetBackground 93 | }; 94 | }; 95 | 96 | export const useAtramentState = (keys = undefined) => { 97 | const atrament = useContext(AtramentContext); 98 | return useStore(atrament.store, {keys}); 99 | }; 100 | 101 | export const useAtramentOverlay = () => { 102 | const { evaluateInkFunction, setStateSubkey } = useAtrament(); 103 | const atramentState = useAtramentState([OVERLAY_STORE_KEY]); 104 | 105 | const setOverlayContent = useCallback((overlayName, content) => { 106 | setStateSubkey(OVERLAY_STORE_KEY, 'current', overlayName); 107 | let textContent = content; 108 | const contentArray = content.split('\n'); 109 | const firstLine = contentArray.shift(); 110 | const title = firstLine.match(/\[title\](.+?)\[\/title\]/i); 111 | if (title) { 112 | setStateSubkey(OVERLAY_STORE_KEY, 'title', title[1]); 113 | textContent = contentArray.join('\n'); 114 | } 115 | setStateSubkey(OVERLAY_STORE_KEY, 'content', textContent); 116 | }, [ setStateSubkey ]); 117 | 118 | const refreshOverlay = useCallback(() => { 119 | const currentOverlay = atramentState[OVERLAY_STORE_KEY].current; 120 | if (currentOverlay) { 121 | // refresh active overlay 122 | const result = evaluateInkFunction(currentOverlay); 123 | setOverlayContent(currentOverlay, result.output); 124 | } 125 | }, [ atramentState, setOverlayContent, evaluateInkFunction ]); 126 | 127 | const closeOverlay = useCallback(() => { 128 | setStateSubkey(OVERLAY_STORE_KEY, 'current', null); 129 | setStateSubkey(OVERLAY_STORE_KEY, 'content', ''); 130 | setStateSubkey(OVERLAY_STORE_KEY, 'title', null); 131 | }, [ setStateSubkey ]); 132 | 133 | return { 134 | refreshOverlay, 135 | closeOverlay, 136 | setOverlayContent, 137 | overlay: { 138 | current: atramentState[OVERLAY_STORE_KEY].current, 139 | content: atramentState[OVERLAY_STORE_KEY].content.split('\n'), 140 | title: atramentState[OVERLAY_STORE_KEY].title, 141 | } 142 | } 143 | }; 144 | -------------------------------------------------------------------------------- /src/atrament/init.js: -------------------------------------------------------------------------------- 1 | 2 | import { applicationID, gameFile, gamePath } from 'src/constants'; 3 | 4 | import muteWhenInactive from 'src/utils/mute-when-inactive'; 5 | 6 | import { loadDefaultFont, loadDefaultTheme } from 'src/atrament/load-defaults'; 7 | import { registerSettingsHandlers } from 'src/atrament/settings-handlers' 8 | import registerSceneProcessors from 'src/atrament/scene-processors'; 9 | 10 | import onGameStart from 'src/atrament/on-game-start'; 11 | import onContinueStory from 'src/atrament/on-continue-story'; 12 | 13 | export default async function atramentInit(atrament, Story) { 14 | // show all events in console 15 | atrament.on('*', (event, message) => console.log( 16 | `%c Atrament > ${event} `, 'color: #111111; background-color: #7FDBFF;', 17 | message 18 | )); 19 | // handle settings 20 | registerSettingsHandlers(atrament); 21 | // initialize Atrament 22 | await atrament.init(Story, { 23 | applicationID, 24 | settings: { 25 | fullscreen: false, 26 | animation: true, 27 | mute: false, 28 | volume: 50, 29 | fontSize: 100 30 | } 31 | }); 32 | atrament.on('game/start', () => onGameStart(atrament)); 33 | atrament.on('game/continueStory', () => onContinueStory(atrament)); 34 | // initialize game 35 | await atrament.game.init(gamePath, gameFile); 36 | await atrament.game.initInkStory(); 37 | // load defaults 38 | loadDefaultTheme(atrament); 39 | loadDefaultFont(atrament); 40 | // register scene processors 41 | registerSceneProcessors(atrament); 42 | // mute when tab is inactive 43 | muteWhenInactive(atrament); 44 | // set window title 45 | const metadata = atrament.state.get().metadata; 46 | if (metadata.title) { 47 | atrament.interfaces.platform.setTitle(metadata.title); 48 | } 49 | // done 50 | } 51 | -------------------------------------------------------------------------------- /src/atrament/load-defaults.js: -------------------------------------------------------------------------------- 1 | import { gameDefaultTheme, gameDefaultFont } from 'src/constants'; 2 | 3 | export function loadDefaultTheme(atrament) { 4 | // set initial theme from game 5 | const defaultTheme = atrament.state.get().metadata.theme || gameDefaultTheme; 6 | if (!atrament.settings.get('theme')) { 7 | atrament.settings.set('theme', defaultTheme); 8 | } 9 | } 10 | 11 | export function loadDefaultFont(atrament) { 12 | // set initial font from game 13 | const defaultFont = atrament.state.get().metadata.font || gameDefaultFont; 14 | if (!atrament.settings.get('font')) { 15 | atrament.settings.set('font', defaultFont); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/atrament/markup.js: -------------------------------------------------------------------------------- 1 | import MarkupComponents from 'src/components/markup'; 2 | import HTMLFragment from 'src/components/markup/html-fragment'; 3 | 4 | const containsHTML = (str) => /<\/?[a-z][\s\S]*>/i.test(str); 5 | 6 | function replaceWithComponent(text, regexp, replacer) { 7 | if (typeof text !== 'string') { 8 | return text; 9 | } 10 | const mentions = text.match(regexp); 11 | if (!mentions) { 12 | return text; 13 | } 14 | const splitted = text.split(regexp); 15 | const result = splitted.flatMap((fragment, index) => { 16 | return (index < mentions.length 17 | ? [fragment, replacer(mentions[index], markup)] 18 | : [fragment]); 19 | }); 20 | return result; 21 | } 22 | 23 | export default function markup(text) { 24 | let processedText = [text]; 25 | // find matched markup and its length 26 | const processingQueue = []; 27 | MarkupComponents.forEach(component => { 28 | const mentions = text.match(component.regexp); 29 | if (mentions) { 30 | mentions.forEach((m) => processingQueue.push({ 31 | component, 32 | size: m.length 33 | })); 34 | } 35 | }); 36 | // start from the longest markup elements, which may contain others 37 | processingQueue 38 | .sort((a, b) => b.size - a.size) 39 | .forEach(({ component }) => { 40 | processedText = processedText.flatMap( 41 | (item) => replaceWithComponent( 42 | item, 43 | component.regexp, 44 | component.replacer 45 | ) 46 | ); 47 | }); 48 | return processedText.map((item, index) => 49 | typeof item === 'string' && containsHTML(item) 50 | ? HTMLFragment({index, item}) 51 | : item 52 | ); 53 | } 54 | -------------------------------------------------------------------------------- /src/atrament/on-continue-story.js: -------------------------------------------------------------------------------- 1 | export default function onContinueStory(atrament) { 2 | // save current story path into game.$story_path for debugging purposes 3 | const path = atrament.ink.story().state.previousPathString; 4 | atrament.state.setSubkey('game', '$story_path', path); 5 | } 6 | -------------------------------------------------------------------------------- /src/atrament/on-game-start.js: -------------------------------------------------------------------------------- 1 | import { TOOLBAR_DEFAULT, TOOLBAR_STORE_KEY, OVERLAY_STORE_KEY, ERROR_STORE_KEY } from "src/constants"; 2 | 3 | function registerToolbarHandler(atrament, toolbarFunction) { 4 | const refreshToolbar = () => { 5 | let result; 6 | try { 7 | result = atrament.ink.evaluateFunction(toolbarFunction, [], true); 8 | atrament.state.setKey(TOOLBAR_STORE_KEY, result.output); 9 | } catch (e) { 10 | atrament.ink.story().onError(e.toString()); 11 | } 12 | } 13 | const delayedRefreshToolbar = () => setTimeout(refreshToolbar, 0); 14 | // refresh toolbar on continueStory and function evaluation (buttons etc) 15 | atrament.on('game/continueStory', delayedRefreshToolbar); 16 | atrament.on('ink/evaluateFunction', (params) => { 17 | if (params.function !== toolbarFunction) { 18 | delayedRefreshToolbar(); 19 | } 20 | }); 21 | // run refresh toolbar before game starts 22 | refreshToolbar(); 23 | } 24 | 25 | 26 | export default function onGameStart(atrament) { 27 | // register error handler 28 | atrament.ink.onError((error) => atrament.state.setKey(ERROR_STORE_KEY, error)); 29 | // reset overlay state 30 | atrament.state.setKey(OVERLAY_STORE_KEY, { 31 | current: null, 32 | content: '', 33 | title: null 34 | }); 35 | // configure toolbar 36 | const metadata = atrament.state.get().metadata; 37 | if (metadata.toolbar) { 38 | registerToolbarHandler(atrament, metadata.toolbar); 39 | } else if (metadata.title) { 40 | atrament.state.setKey(TOOLBAR_STORE_KEY, metadata.title); 41 | } else { 42 | atrament.state.setKey(TOOLBAR_STORE_KEY, TOOLBAR_DEFAULT); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/atrament/scene-processors.js: -------------------------------------------------------------------------------- 1 | // Atrament scene processors 2 | import arrayShuffle from "src/utils/array-shuffle"; 3 | 4 | 5 | function sceneBackground(scene) { 6 | // BACKGROUND and PAGE_BACKGROUND can be set to false, so we check if variable is defined 7 | const background = scene.tags?.BACKGROUND; 8 | if (typeof background !== 'undefined') { 9 | scene.images.push(background); 10 | this.state.setSubkey('game', 'background', background); 11 | } 12 | const backgroundPage = scene.tags?.PAGE_BACKGROUND; 13 | if (typeof backgroundPage !== 'undefined') { 14 | scene.images.push(backgroundPage); 15 | this.state.setSubkey('game', 'background_page', backgroundPage); 16 | } 17 | } 18 | 19 | function shuffleChoices(scene) { 20 | if (scene.tags?.SHUFFLE_CHOICES) { 21 | scene.choices = arrayShuffle(scene.choices); 22 | } 23 | } 24 | 25 | export default function registerSceneProcessors(atrament) { 26 | [sceneBackground, shuffleChoices] 27 | .forEach((p) => atrament.game.defineSceneProcessor(p.bind(atrament))); 28 | } 29 | -------------------------------------------------------------------------------- /src/atrament/settings-handlers.js: -------------------------------------------------------------------------------- 1 | import { applyTheme } from 'src/themes'; 2 | import { applyFont } from 'src/fonts'; 3 | 4 | export function registerSettingsHandlers(atrament) { 5 | atrament.settings.defineHandler('theme', (oldV, value) => { 6 | applyTheme(value); 7 | }); 8 | atrament.settings.defineHandler('font', (oldV, value) => { 9 | applyFont(value); 10 | }); 11 | atrament.settings.defineHandler('fontSize', (oldV, value) => { 12 | document.documentElement.style.setProperty('--game-font-size', `${value}%`); 13 | }); 14 | atrament.settings.defineHandler('animation', (oldV, value) => { 15 | if (value) { 16 | document.documentElement.style.removeProperty('--animation-disabled'); 17 | } else { 18 | document.documentElement.style.setProperty('--animation-disabled', '0s'); 19 | } 20 | }); 21 | atrament.settings.defineHandler('fullscreen', (oldV, value) => { 22 | atrament.interfaces.platform.setFullscreen(value, (v) => { 23 | atrament.settings.set('fullscreen', v); 24 | atrament.settings.save(); 25 | }); 26 | }); 27 | } 28 | -------------------------------------------------------------------------------- /src/components/app.jsx: -------------------------------------------------------------------------------- 1 | import { h } from 'preact'; 2 | import { useEffect, useState } from 'preact/hooks'; 3 | import { TranslationsProvider } from '@eo-locale/preact'; 4 | import { AtramentContext } from 'src/context'; 5 | import atramentInit from 'src/atrament/init'; 6 | import locales from 'src/i18n.json'; 7 | import { appLanguage } from 'src/constants'; 8 | 9 | import ApplicationWrapper from 'src/components/ui/application-wrapper'; 10 | import ErrorModal from 'src/components/ui/error-modal'; 11 | import Container from 'src/components/ui/container'; 12 | import Loading from 'src/components/ui/loading'; 13 | import AppRouter from './router'; 14 | 15 | function App() { 16 | const [ atrament, setAtrament ] = useState(null); 17 | const [ initError, setInitError ] = useState(null); 18 | 19 | useEffect(() => { 20 | const startEngine = async () => { 21 | const { default: atrament } = await import(/* webpackChunkName: "atrament" */ "@atrament/web"); 22 | // import inkjs 23 | const { Story } = await import(/* webpackChunkName: "inkjs" */ "inkjs"); 24 | // initialize engine 25 | try { 26 | await atramentInit(atrament, Story); 27 | // done 28 | setAtrament(atrament); 29 | } catch (e) { 30 | console.error(e); 31 | setInitError(e.message); 32 | } 33 | }; 34 | // application is ready 35 | startEngine(); 36 | }, []); 37 | 38 | return ( 39 | 40 | 41 | 42 | {atrament 43 | ? 44 | : initError 45 | ? 46 | : 47 | } 48 | 49 | 50 | 51 | ); 52 | } 53 | 54 | export default App; 55 | -------------------------------------------------------------------------------- /src/components/markup/banner/index.jsx: -------------------------------------------------------------------------------- 1 | import { h } from 'preact'; 2 | import style from './index.module.css'; 3 | 4 | // [banner style=highlight allcaps=false]text in info block[/banner] 5 | 6 | import getTagAttributes from 'src/utils/get-tag-attributes'; 7 | 8 | const BannerBlock = ({children, options}) => { 9 | const classList = [ 10 | style.bannerblock, 11 | options.style === 'accent' ? style.accent : '', 12 | options.allcaps ? style.allcaps : '' 13 | ].join(' '); 14 | return (
{children}
); 15 | }; 16 | 17 | export default { 18 | regexp: /\[banner(?:\s+[^\]]+)?\].*?\[\/banner\]/ig, 19 | replacer: (el, markup) => { 20 | const fragments = el.match(/\[banner(.*?)\](.*?)\[\/banner\]/i); 21 | const options = getTagAttributes(fragments[1]); 22 | return ({markup(fragments[2])}); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/components/markup/banner/index.module.css: -------------------------------------------------------------------------------- 1 | .bannerblock { 2 | padding: 1rem; 3 | font-size: 2em; 4 | text-align: center; 5 | line-height: normal; 6 | border-top: 1px solid var(--fg-color); 7 | border-bottom: 1px solid var(--fg-color); 8 | color: var(--fg-color); 9 | } 10 | 11 | .accent { 12 | border-top: 1px solid var(--accent-fg-color); 13 | border-bottom: 1px solid var(--accent-fg-color); 14 | color: var(--accent-fg-color); 15 | } 16 | 17 | .allcaps { 18 | text-transform: uppercase; 19 | } 20 | -------------------------------------------------------------------------------- /src/components/markup/block/index.jsx: -------------------------------------------------------------------------------- 1 | import { h } from 'preact'; 2 | import getTagAttributes from 'src/utils/get-tag-attributes'; 3 | 4 | // [block width=50% align=left]text in bar[/block] 5 | 6 | export default { 7 | regexp: /\[block(?:\s+[^\]]+)?\].+?\[\/block\]/ig, 8 | replacer: (el, markup) => { 9 | const fragments = el.match(/\[block(.*?)\](.+?)\[\/block\]/i); 10 | let options = {}; 11 | if (fragments[1]) { 12 | options = getTagAttributes(fragments[1]); 13 | } 14 | const blockStyle = { 15 | display: 'inline-block', 16 | width: options.width || '100%', 17 | 'text-align': options.align || 'inherit', 18 | 'vertical-align': options.valign || 'top', 19 | }; 20 | return (
{markup(fragments[2])}
); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/components/markup/button/index.jsx: -------------------------------------------------------------------------------- 1 | import { h } from 'preact'; 2 | import style from './index.module.css'; 3 | import { useCallback } from "preact/hooks"; 4 | 5 | import getTagAttributes from 'src/utils/get-tag-attributes'; 6 | import { useAtrament, useAtramentOverlay } from 'src/atrament/hooks'; 7 | 8 | // [button onclick=function]button text[/button] 9 | 10 | const InlineButtonComponent = ({ children, options }) => { 11 | const { evaluateInkFunction } = useAtrament(); 12 | const { setOverlayContent, refreshOverlay } = useAtramentOverlay(); 13 | 14 | const clickHandler = useCallback((e) => { 15 | e.stopPropagation(); 16 | const inkFn = options.onclick; 17 | const result = evaluateInkFunction(inkFn); 18 | if (result.output) { 19 | setOverlayContent(inkFn, result.output); 20 | } else { 21 | refreshOverlay(); 22 | } 23 | }, [ evaluateInkFunction, setOverlayContent, refreshOverlay, options.onclick ]); 24 | 25 | let buttonStyle = options.bordered === false ? style.inline_button : style.bordered_button; 26 | return ( 27 | 34 | ); 35 | } 36 | 37 | export default { 38 | regexp: /\[button[ =].+?\].+?\[\/button\]/ig, 39 | replacer: (el, markup) => { 40 | const fragments = el.match(/\[button([ =].+?)\](.+?)\[\/button\]/i); 41 | let attributes = fragments[1]; 42 | if (attributes.startsWith('=')) { 43 | attributes = `onclick${attributes}`; 44 | } 45 | const options = getTagAttributes(attributes); 46 | return ({markup(fragments[2])}); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/components/markup/button/index.module.css: -------------------------------------------------------------------------------- 1 | .inline_button { 2 | background-color: var(--bg-color); 3 | color: var(--fg-color); 4 | border: 1px solid var(--bg-color); 5 | border-radius: var(--border-radius-inline); 6 | text-decoration: none; 7 | cursor: pointer; 8 | } 9 | 10 | .inline_button:hover { 11 | border: 1px solid var(--fg-color); 12 | } 13 | 14 | .inline_button:disabled { 15 | border: 1px solid var(--bg-color); 16 | opacity: 0.5; 17 | } 18 | 19 | .bordered_button { 20 | background-color: var(--bg-color); 21 | color: var(--fg-color); 22 | text-decoration: none; 23 | cursor: pointer; 24 | border: 1px solid var(--fg-color); 25 | border-radius: var(--border-radius-inline); 26 | } 27 | 28 | .bordered_button:hover { 29 | background-color: var(--fg-color); 30 | color: var(--bg-color); 31 | } 32 | 33 | .bordered_button:disabled { 34 | background-color: var(--bg-color); 35 | color: var(--fg-color); 36 | opacity: 0.5; 37 | } 38 | -------------------------------------------------------------------------------- /src/components/markup/css/index.jsx: -------------------------------------------------------------------------------- 1 | import { h } from 'preact'; 2 | 3 | import getTagAttributes from 'src/utils/get-tag-attributes'; 4 | 5 | // [css class=classname style="style applied"]text with CSS class and style applied[/css] 6 | 7 | export default { 8 | regexp: /\[css(?:\s+[^\]]+)?\].+?\[\/css\]/ig, 9 | replacer: (el, markup) => { 10 | const fragments = el.match(/\[css(.*?)\](.+?)\[\/css\]/i); 11 | let options = {}; 12 | if (fragments[1]) { 13 | options = getTagAttributes(fragments[1]); 14 | } 15 | return ({markup(fragments[2])}); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/components/markup/font/index.jsx: -------------------------------------------------------------------------------- 1 | import { h } from 'preact'; 2 | 3 | // [font=Fira Sans]text with font applied[/font] 4 | 5 | export default { 6 | regexp: /\[font=.+?\].+?\[\/font\]/ig, 7 | replacer: (el, markup) => { 8 | const fragments = el.match(/\[font=(.+?)\](.+?)\[\/font\]/i); 9 | return ({markup(fragments[2])}); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/components/markup/highlight/index.jsx: -------------------------------------------------------------------------------- 1 | import { h } from 'preact'; 2 | import getTagAttributes from 'src/utils/get-tag-attributes'; 3 | 4 | // [highlight color=black bgcolor=red]highlighted text[/highlight] 5 | 6 | export default { 7 | regexp: /\[highlight(?:\s+[^\]]+)?\].+?\[\/highlight\]/ig, 8 | replacer: (el, markup) => { 9 | const fragments = el.match(/\[highlight(.*?)\](.+?)\[\/highlight\]/i); 10 | let options = {}; 11 | if (fragments[1]) { 12 | options = getTagAttributes(fragments[1]); 13 | } 14 | const highlightStyle = { 15 | 'background-color': options.bgcolor || 'var(--accent-bg-color)', 16 | color: options.color || 'var(--accent-fg-color)', 17 | padding: options.bgcolor ? '0.1em' : 'inherit', 18 | 'box-decoration-break': 'clone' 19 | }; 20 | return ({markup(fragments[2])}); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/components/markup/html-fragment.jsx: -------------------------------------------------------------------------------- 1 | const HTMLFragment = ({index, item}) => (); 2 | export default HTMLFragment; 3 | -------------------------------------------------------------------------------- /src/components/markup/index.js: -------------------------------------------------------------------------------- 1 | import Banner from './banner'; 2 | import Block from './block'; 3 | import Button from './button'; 4 | import spanCSS from './css'; 5 | import Font from './font'; 6 | import Highlight from './highlight'; 7 | import Info from './info'; 8 | import InlineImage from './inline-image'; 9 | import Input from './input'; 10 | import InlineLink from './link'; 11 | import Picture from './picture'; 12 | import Progress from './progress'; 13 | import Spoiler from './spoiler'; 14 | import Table from './table'; 15 | import webURL from './url'; 16 | import Video from './video'; 17 | 18 | export default [ 19 | Banner, 20 | Block, 21 | Button, 22 | spanCSS, 23 | Font, 24 | Highlight, 25 | Info, 26 | InlineImage, 27 | Input, 28 | InlineLink, 29 | Picture, 30 | Progress, 31 | Spoiler, 32 | Table, 33 | webURL, 34 | Video 35 | ]; 36 | -------------------------------------------------------------------------------- /src/components/markup/info/index.jsx: -------------------------------------------------------------------------------- 1 | import { h } from 'preact'; 2 | import style from './index.module.css'; 3 | 4 | // [info font=system side=highlight]text in info block[/info] 5 | 6 | import getTagAttributes from 'src/utils/get-tag-attributes'; 7 | 8 | const InfoBlock = ({children, options}) => { 9 | const classList = [ 10 | style.infoblock, 11 | options.font === 'system' ? style.font_ui : '', 12 | options.side === 'accent' ? style.side_accent : '', 13 | options.side === 'highlight' ? style.side_highlight : '' 14 | ].join(' '); 15 | return (
{children}
); 16 | }; 17 | 18 | export default { 19 | regexp: /\[info(?:\s+[^\]]+)?\].*?\[\/info\]/ig, 20 | replacer: (el, markup) => { 21 | const fragments = el.match(/\[info(.*?)\](.*?)\[\/info\]/i); 22 | const options = getTagAttributes(fragments[1]); 23 | return ({markup(fragments[2])}); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/components/markup/info/index.module.css: -------------------------------------------------------------------------------- 1 | .infoblock { 2 | display: block; 3 | width: 100%; 4 | background-color: var(--shade-color); 5 | padding: 0.5em; 6 | border-radius: var(--border-radius); 7 | } 8 | 9 | .font_ui { 10 | font-family: var(--font-interface); 11 | } 12 | 13 | .side_highlight { 14 | border-left: 0.3rem solid var(--fg-color); 15 | } 16 | 17 | .side_accent { 18 | border-left: 0.3rem solid var(--accent-fg-color); 19 | } 20 | -------------------------------------------------------------------------------- /src/components/markup/inline-image/index.jsx: -------------------------------------------------------------------------------- 1 | import { h } from 'preact'; 2 | import style from './index.module.css'; 3 | import { useAtrament } from 'src/atrament/hooks'; 4 | 5 | // [img]path/to/image.jpg[/img] 6 | 7 | const InlineImage = ({ src }) => { 8 | const { getAssetPath } = useAtrament(); 9 | return (); 10 | } 11 | 12 | export default { 13 | regexp: /\[img\].+?\[\/img\]/ig, 14 | replacer: (el) => { 15 | const fragments = el.match(/\[img\](.+?)\[\/img\]/i); 16 | return (); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/components/markup/inline-image/index.module.css: -------------------------------------------------------------------------------- 1 | .inline_image { 2 | height: 1.5em; 3 | } 4 | -------------------------------------------------------------------------------- /src/components/markup/input/index.jsx: -------------------------------------------------------------------------------- 1 | import { h } from 'preact'; 2 | import { useContext, useEffect, useState } from 'preact/hooks'; 3 | import style from './index.module.css'; 4 | 5 | import getTagAttributes from 'src/utils/get-tag-attributes'; 6 | import { useAtrament } from 'src/atrament/hooks'; 7 | import { ActiveContentContext } from 'src/context'; 8 | 9 | // [input var=variable placeholder="placeholder text" type=number] 10 | 11 | const Input = ({options}) => { 12 | const isActive = useContext(ActiveContentContext); 13 | 14 | const [ defaultValue, setDefaultValue ] = useState(null); 15 | const { getInkVariable, setInkVariable } = useAtrament(); 16 | useEffect( 17 | () => setDefaultValue(getInkVariable(options.var)), 18 | [getInkVariable, options.var] 19 | ); 20 | const onInput = (e) => { 21 | let targetValue = e.srcElement.value || defaultValue; 22 | if (options.type === 'number') { 23 | targetValue = +targetValue; 24 | } 25 | setInkVariable(options.var, targetValue); 26 | setDefaultValue(targetValue); 27 | }; 28 | const inputType = options.type === 'number' ? 'number' : 'text'; 29 | return ( 30 | 31 | ); 32 | }; 33 | 34 | export default { 35 | regexp: /\[input .+?\]/ig, 36 | replacer: (el) => { 37 | const fragments = el.match(/\[input(.+?)\]/i); 38 | const options = getTagAttributes(fragments[1]); 39 | return (); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/components/markup/input/index.module.css: -------------------------------------------------------------------------------- 1 | .input { 2 | width: 100%; 3 | padding: 0.25rem; 4 | border: 2px solid var(--fg-color); 5 | border-radius: var(--border-radius-inline); 6 | background-color: var(--bg-color); 7 | color: var(--fg-color); 8 | } 9 | -------------------------------------------------------------------------------- /src/components/markup/link/index.jsx: -------------------------------------------------------------------------------- 1 | import { h } from 'preact'; 2 | import style from './index.module.css'; 3 | import { useCallback } from "preact/hooks"; 4 | import { ERROR_STORE_KEY } from 'src/constants'; 5 | import { useAtrament, useAtramentState } from 'src/atrament/hooks'; 6 | 7 | // [link=target choice text]Text[/link] 8 | 9 | const InlineLink = ({ children, choice }) => { 10 | const { atrament, makeChoice, continueStory } = useAtrament(); 11 | const atramentState = useAtramentState(['scenes']); 12 | 13 | const clickHandler = useCallback(() => { 14 | const lastSceneIndex = atramentState.scenes.length - 1; 15 | const currentScene = atramentState.scenes[lastSceneIndex]; 16 | const chosen = currentScene.choices.findIndex((item) => item.choice === choice); 17 | if (chosen < 0) { 18 | atrament.state.setKey(ERROR_STORE_KEY, `[link=${choice}] leads to nonexistent choice!`); 19 | return; 20 | } 21 | makeChoice(chosen); 22 | continueStory(); 23 | }, [ atrament, continueStory, makeChoice, choice, atramentState.scenes ]); 24 | 25 | return ( 26 | 31 | {children} 32 | 33 | ); 34 | } 35 | 36 | export default { 37 | regexp: /\[link=.+?\].+?\[\/link\]/ig, 38 | replacer: (el, markup) => { 39 | const fragments = el.match(/\[link=(.+?)\](.+?)\[\/link\]/i); 40 | return ({markup(fragments[2])}); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/components/markup/link/index.module.css: -------------------------------------------------------------------------------- 1 | .inline_link, .inline_link:visited { 2 | color: var(--fg-color); 3 | } 4 | -------------------------------------------------------------------------------- /src/components/markup/picture/index.jsx: -------------------------------------------------------------------------------- 1 | import { h } from 'preact'; 2 | import { useAtrament } from 'src/atrament/hooks'; 3 | import getTagAttributes from 'src/utils/get-tag-attributes'; 4 | 5 | import ContainerImage from 'src/components/ui/container-image' 6 | 7 | // [picture align=left]path/to/image.jpg[/picture] 8 | 9 | const Picture = ({ options, src }) => { 10 | const { getAssetPath } = useAtrament(); 11 | const pictureOptions = { 12 | fullsize: true 13 | }; 14 | ['leftmargin', 'rightmargin', 'width'].forEach((k) => { 15 | if (options[k]) { 16 | pictureOptions[k] = options[k]; 17 | } 18 | }); 19 | return (); 20 | } 21 | 22 | export default { 23 | regexp: /\[picture(?:\s+[^\]]+)?\].*?\[\/picture\]/ig, 24 | replacer: (el) => { 25 | const fragments = el.match(/\[picture(.*?)\](.*?)\[\/picture\]/i); 26 | const options = getTagAttributes(fragments[1]); 27 | return (); 28 | } 29 | } -------------------------------------------------------------------------------- /src/components/markup/progress/index.jsx: -------------------------------------------------------------------------------- 1 | import { h } from 'preact'; 2 | import style from './index.module.css'; 3 | 4 | // [progress min=0 max=100 value=99 style=accent]text in bar[/progress] 5 | 6 | import getTagAttributes from 'src/utils/get-tag-attributes'; 7 | 8 | const Progress = ({options, children}) => { 9 | const min = +options.min || 0; 10 | const max = +options.max || 100; 11 | const value = +options.value || 0; 12 | let width = value ? ((value - min) * 100)/(max - min) : 0; 13 | if (width > 100) { 14 | width = 100; 15 | } 16 | if (width < 0) { 17 | width = 0; 18 | } 19 | 20 | const classList = [ 21 | style.progress_bar, 22 | options.style === 'accent' ? style.accent : style.standard 23 | ].join(' '); 24 | 25 | return ( 26 |
27 |
28 |
{children} 
29 |
30 | ); 31 | }; 32 | 33 | export default { 34 | regexp: /\[progress(?:\s+[^\]]+)?\].*?\[\/progress\]/ig, 35 | replacer: (el, markup) => { 36 | const fragments = el.match(/\[progress(.+?)\](.*?)\[\/progress\]/i); 37 | const options = getTagAttributes(fragments[1]); 38 | return ({markup(fragments[2])}); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/components/markup/progress/index.module.css: -------------------------------------------------------------------------------- 1 | .progress_frame { 2 | border: 1px solid var(--fg-color); 3 | border-radius: var(--border-radius-inline); 4 | position: relative; 5 | } 6 | 7 | .progress_bar { 8 | position: absolute; 9 | height: 100%; 10 | text-align: left; 11 | opacity: 0.3; 12 | } 13 | 14 | .standard { 15 | background-color: var(--fg-color); 16 | } 17 | 18 | .accent { 19 | background-color: var(--accent-fg-color); 20 | } 21 | 22 | .progress_content { 23 | padding: 0.25rem; 24 | } 25 | -------------------------------------------------------------------------------- /src/components/markup/spoiler/index.jsx: -------------------------------------------------------------------------------- 1 | import { h } from 'preact'; 2 | import style from './index.module.css'; 3 | import { useState } from "preact/hooks"; 4 | 5 | // [spoiler]text in spoiler[/spoiler] 6 | 7 | const classes = [ 8 | style.hidden, 9 | style.revealed 10 | ]; 11 | 12 | const Spoiler = ({children}) => { 13 | const [ currentStyle, setStyle ] = useState(0); 14 | return ( 15 | setStyle(1 - currentStyle)} 18 | > 19 | {children} 20 | 21 | ); 22 | }; 23 | 24 | export default { 25 | regexp: /\[spoiler\].*?\[\/spoiler\]/ig, 26 | replacer: (el, markup) => { 27 | const fragments = el.match(/\[spoiler\](.*?)\[\/spoiler\]/i); 28 | return ({markup(fragments[1])}); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/components/markup/spoiler/index.module.css: -------------------------------------------------------------------------------- 1 | .hidden { 2 | background-color: var(--fg-color); 3 | cursor: pointer; 4 | border-radius: var(--border-radius-inline); 5 | } 6 | 7 | .hidden span { 8 | opacity: 0; 9 | user-select: none; 10 | } 11 | 12 | .revealed { 13 | background-color: var(--bg-color); 14 | transition: background-color var(--animation-disabled, 0.2s) linear; 15 | } 16 | -------------------------------------------------------------------------------- /src/components/markup/table/index.jsx: -------------------------------------------------------------------------------- 1 | import { h } from 'preact'; 2 | 3 | import getTagAttributes from 'src/utils/get-tag-attributes'; 4 | 5 | import Table from 'src/components/ui/table'; 6 | 7 | // [table]<> 8 | // [header]Name | Value[/header]<> 9 | // [row][/row] 10 | // [/table] 11 | 12 | const parseHeader = (header, markup) => header 13 | .split(/\[ \]/) 14 | .map((item) => ({ 15 | style: {textAlign: 'left'}, 16 | name: markup(item) 17 | })); 18 | 19 | const parseRow = (row, markup) => { 20 | const currentRow = row.match(/\[row\](.+?)\[\/row\]/i); 21 | return currentRow[1].split(/\[ \]/).map(markup); 22 | } 23 | 24 | export default { 25 | regexp: /\[table(?:\s+[^\]]+)?\].+?\[\/table\]/ig, 26 | replacer: (el, markup) => { 27 | const fragments = el.match(/\[table(.*?)\](.+?)\[\/table\]/i); 28 | let options = {}; 29 | if (fragments[1]) { 30 | options = getTagAttributes(fragments[1]); 31 | } 32 | const tableHeader = fragments[2].match(/\[header\](.+?)\[\/header\]/i); 33 | const tableRows = fragments[2].match(/\[row\].+?\[\/row\]/ig); 34 | const columns = tableHeader ? parseHeader(tableHeader[1], markup) : []; 35 | const rows = tableRows ? tableRows.map((row) => parseRow(row, markup)) : []; 36 | const columnWidth = options.columns ? options.columns.split(/\s+/g) : []; 37 | return (); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/components/markup/url/index.jsx: -------------------------------------------------------------------------------- 1 | import { h } from 'preact'; 2 | 3 | // [url=URL]Text[/url] 4 | 5 | export default { 6 | regexp: /\[url=.+?\].+?\[\/url\]/ig, 7 | replacer: (el, markup) => { 8 | const fragments = el.match(/\[url=(.+?)\](.+?)\[\/url\]/i); 9 | return ({markup(fragments[2])}); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/components/markup/video/index.jsx: -------------------------------------------------------------------------------- 1 | import { h } from 'preact'; 2 | import { useAtrament } from 'src/atrament/hooks'; 3 | import getTagAttributes from 'src/utils/get-tag-attributes'; 4 | 5 | // [video]path/to/video.mp4[/video] 6 | 7 | const Video = ({ src, options }) => { 8 | const { getAssetPath } = useAtrament(); 9 | return ( 10 | 20 | ); 21 | } 22 | 23 | export default { 24 | regexp: /\[video(?:\s+[^\]]+)?\].+?\[\/video\]/ig, 25 | replacer: (el) => { 26 | const fragments = el.match(/\[video(.*?)\](.+?)\[\/video\]/i); 27 | let options = {}; 28 | if (fragments[1]) { 29 | options = getTagAttributes(fragments[1]); 30 | } 31 | return (
27 | {pageSize > 0 && 28 | 40 | } 41 | {columns.length > 0 && 42 | 43 | 44 | {columns.map((th, i) => { 45 | const thStyle = th.style || {}; 46 | if (columnWidth[i]) { 47 | thStyle.width = columnWidth[i]; 48 | } 49 | return ( 50 | 51 | ) 52 | })} 53 | 54 | 55 | } 56 | {displayData.length > 0 && 57 | 58 | {displayData.map((tr, row) => ( 59 | 60 | {tr.map((td, col) => { 61 | const tdStyle = {}; 62 | if (columnWidth[col]) { 63 | tdStyle.width = columnWidth[col]; 64 | } 65 | return ( 66 | 67 | ); 68 | })} 69 | 70 | ))} 71 | 72 | } 73 |
29 | {[...Array(pages).keys()].map((p) => 30 | 38 | )} 39 |
{th.name}
{td}
74 | ); 75 | } 76 | 77 | export default Table; 78 | -------------------------------------------------------------------------------- /src/components/ui/table/index.module.css: -------------------------------------------------------------------------------- 1 | .table { 2 | border-collapse: collapse; 3 | color: var(--fg-color); 4 | width: 100%; 5 | } 6 | 7 | .cell_full { 8 | padding: 0; 9 | } 10 | 11 | .cell_padding { 12 | padding: 0.5em 0.5em; 13 | } 14 | 15 | .thead_border { 16 | border-bottom: 2px solid var(--shade-color); 17 | } 18 | 19 | .tbody_border { 20 | border-bottom: 1px solid var(--shade-color); 21 | } 22 | 23 | .table tbody tr:last-of-type { 24 | border-bottom: none; 25 | } 26 | 27 | .caption { 28 | caption-side: bottom; 29 | } 30 | 31 | .caption button { 32 | cursor: pointer; 33 | border: none; 34 | border-radius: var(--border-radius-inline); 35 | background-color: var(--shade-color); 36 | margin: 0.25rem; 37 | padding: 0.25rem; 38 | } 39 | 40 | .page { 41 | color: var(--fg-color); 42 | } 43 | 44 | .page_active { 45 | color: var(--accent-fg-color); 46 | } -------------------------------------------------------------------------------- /src/components/ui/tabs/index.jsx: -------------------------------------------------------------------------------- 1 | import { h } from 'preact'; 2 | import style from './index.module.css'; 3 | import { useState } from 'preact/hooks'; 4 | 5 | const Tab = ({ children}) =>
{children}
; 6 | 7 | const TabContent = ({ children, isHidden }) => ( 8 | 11 | ); 12 | 13 | const TabHeader = ({ children, title, isActive, onSelect }) => ( 14 | 17 | ); 18 | 19 | const Tabs = ({ children }) => { 20 | const [ activeTab, setActiveTab ] = useState(0); 21 | 22 | return ( 23 |
24 |
    25 | {children.map((tab, id) => 26 | setActiveTab(id)} 31 | > 32 | {tab.props.title} 33 | 34 | )} 35 |
36 | {children.map((tab, id) => 37 | {tab.props.children} 38 | )} 39 |
40 | ); 41 | } 42 | 43 | export { 44 | Tab, 45 | Tabs 46 | } 47 | -------------------------------------------------------------------------------- /src/components/ui/tabs/index.module.css: -------------------------------------------------------------------------------- 1 | .tab { 2 | 3 | } 4 | 5 | .tablist { 6 | display: flex; 7 | flex-direction: row; 8 | margin: 0; 9 | padding: 0; 10 | border-bottom: 1px solid var(--fg-color); 11 | gap: 1em; 12 | } 13 | 14 | .tabheader { 15 | display: flex; 16 | color: var(--fg-color); 17 | } 18 | 19 | .tabheader a { 20 | display: block; 21 | text-decoration: none; 22 | padding: 0.5em; 23 | color: var(--fg-color); 24 | } 25 | 26 | .tabheader a.active { 27 | color: var(--accent-fg-color); 28 | } 29 | 30 | .tabheader a:hover { 31 | color: var(--bg-color); 32 | background-color: var(--fg-color); 33 | } 34 | 35 | -------------------------------------------------------------------------------- /src/components/ui/text-paragraph/index.jsx: -------------------------------------------------------------------------------- 1 | import { h } from 'preact'; 2 | import style from './index.module.css'; 3 | 4 | const TextParagraph = ({ children }) => ( 5 |
6 | {children} 7 |
8 | ); 9 | 10 | export default TextParagraph; 11 | -------------------------------------------------------------------------------- /src/components/ui/text-paragraph/index.module.css: -------------------------------------------------------------------------------- 1 | .text_paragraph { 2 | display: block; 3 | margin-block-end: 1em; 4 | margin-inline-start: 0; 5 | margin-inline-end: 0; 6 | unicode-bidi: isolate; 7 | } 8 | -------------------------------------------------------------------------------- /src/components/ui/toggle/index.jsx: -------------------------------------------------------------------------------- 1 | import { h } from 'preact'; 2 | import style from './index.module.css'; 3 | 4 | const Toggle = ({enabled, onChange}) => ( 5 |
6 | 7 |
8 | ); 9 | 10 | export default Toggle; 11 | 12 | -------------------------------------------------------------------------------- /src/components/ui/toggle/index.module.css: -------------------------------------------------------------------------------- 1 | .checkbox_wrapper { 2 | display: inline-block; 3 | padding: 1px; 4 | } 5 | 6 | .checkbox_wrapper .checkbox { 7 | appearance: none; 8 | background-color: var(--bg-color); 9 | border-radius: 1rem; 10 | border: 1px solid var(--fg-color); 11 | flex-shrink: 0; 12 | height: 1.1rem; 13 | margin: 0; 14 | position: relative; 15 | width: 2rem; 16 | } 17 | 18 | .checkbox_wrapper .checkbox::before { 19 | bottom: -6px; 20 | left: -6px; 21 | right: -6px; 22 | top: -6px; 23 | content: ""; 24 | position: absolute; 25 | } 26 | 27 | .checkbox_wrapper .checkbox, 28 | .checkbox_wrapper .checkbox::after { 29 | transition: all 100ms ease-out; 30 | } 31 | 32 | .checkbox_wrapper .checkbox::after { 33 | background-color: var(--fg-color); 34 | border-radius: 50%; 35 | content: ""; 36 | height: 0.9rem; 37 | left: 1px; 38 | position: absolute; 39 | top: 1px; 40 | width: 0.9rem; 41 | } 42 | 43 | .checkbox_wrapper input[type=checkbox] { 44 | cursor: default; 45 | } 46 | 47 | .checkbox_wrapper .checkbox:checked { 48 | background-color: var(--fg-color); 49 | } 50 | 51 | .checkbox_wrapper .checkbox:checked::after { 52 | background-color: var(--bg-color); 53 | left: 0.95rem; 54 | } 55 | 56 | .checkbox_wrapper :focus:not(.focus-visible) { 57 | outline: 0; 58 | } 59 | -------------------------------------------------------------------------------- /src/components/views/about/atrament-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/technix/atrament-web-ui/6086816b0074b3c022201d801b9b20feafbf45f8/src/components/views/about/atrament-logo.png -------------------------------------------------------------------------------- /src/components/views/about/iftf-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/technix/atrament-web-ui/6086816b0074b3c022201d801b9b20feafbf45f8/src/components/views/about/iftf-logo.png -------------------------------------------------------------------------------- /src/components/views/about/index.jsx: -------------------------------------------------------------------------------- 1 | import { h } from 'preact'; 2 | import { Text } from '@eo-locale/preact'; 3 | 4 | import { appVersion } from 'src/constants'; 5 | import Block from 'src/components/ui/block'; 6 | 7 | import style from './index.module.css'; 8 | import atramentLogoImage from './atrament-logo.png'; 9 | import iftfLogoImage from './iftf-logo.png'; 10 | 11 | const components = [ 12 | ["Atrament Core", "https://github.com/technix/atrament-core"], 13 | ["Atrament Web", "https://github.com/technix/atrament-web"], 14 | ["Atrament Web UI", "https://github.com/technix/atrament-web-ui"], 15 | ["InkJS", "https://github.com/y-lohse/inkjs"], 16 | ["Preact", "https://preactjs.com/"], 17 | ["Howler", "https://howlerjs.com/"], 18 | ["Nanostores", "https://github.com/nanostores/nanostores"], 19 | ["LocalForage", "https://github.com/localForage/localForage"], 20 | ["fflate", "https://github.com/101arrowz/fflate"], 21 | ]; 22 | const lastComponent = components.length - 1; 23 | 24 | const A = ({href, children}) => ({children}); 25 | const iftfLink = 'Interactive Fiction Technology Foundation'; 26 | 27 | const AboutMenu = ({ onClick }) => { 28 | const clickHandler = (e) => e.target.tagName.toLowerCase() === 'a' ? '' : onClick(e); 29 | return ( 30 | 31 |
32 |
33 | 34 |

Atrament {appVersion}

35 |
36 |

https://atrament.ink/

37 |

38 | : {components.map((item, index) => ( 39 | 40 | {item[0]} 41 | {index === lastComponent ? '.' : ','} 42 | ))} 43 |

44 |
45 | 46 |

51 |
52 |
53 |
54 | ); 55 | }; 56 | 57 | export default AboutMenu; -------------------------------------------------------------------------------- /src/components/views/about/index.module.css: -------------------------------------------------------------------------------- 1 | .header { 2 | display: flex; 3 | align-items: center; 4 | margin: 0px; 5 | } 6 | 7 | .header img { 8 | display: block; 9 | flex-grow: 0; 10 | height: 5em; 11 | margin-right: 1em; 12 | } 13 | 14 | .header small { 15 | display: block; 16 | opacity: 0.8; 17 | } 18 | 19 | .infobox { 20 | display: flex; 21 | align-items: center; 22 | } 23 | 24 | .infobox img { 25 | width: 20%; 26 | flex-grow: 0; 27 | margin-right: 1em; 28 | } 29 | -------------------------------------------------------------------------------- /src/components/views/debugger/functions.jsx: -------------------------------------------------------------------------------- 1 | import { h } from 'preact'; 2 | import style from './index.module.css'; 3 | import { useState } from 'preact/hooks'; 4 | import { useAtrament } from 'src/atrament/hooks'; 5 | import { Text, useTranslator } from '@eo-locale/preact'; 6 | 7 | import Collapse from 'src/components/ui/collapse'; 8 | import Table from 'src/components/ui/table'; 9 | 10 | const DebugFunctions = () => { 11 | const { atrament } = useAtrament(); 12 | const translator = useTranslator(); 13 | const [ fnName, setFnName ] = useState(''); 14 | const [ fnArgs, setFnArgs ] = useState(null); 15 | const [ errorMsg, setErrorMsg ] = useState(''); 16 | const [ outputMsg, setOutputMsg ] = useState(null); 17 | 18 | const parseArgs = (args) => { 19 | let fnArgs = []; 20 | try { 21 | fnArgs = JSON.parse(args); 22 | } catch (e) { 23 | // ignore parsing error 24 | } 25 | return fnArgs; 26 | } 27 | 28 | const handleFnChange = (e) => { 29 | setFnName(e.target.value); 30 | setErrorMsg(''); 31 | setOutputMsg(null); 32 | }; 33 | 34 | const handleArgsChange = (e) => { 35 | setFnArgs(e.target.value); 36 | setErrorMsg(''); 37 | setOutputMsg(null); 38 | }; 39 | 40 | const runFunction = () => { 41 | if (fnName) { 42 | try { 43 | const result = atrament.ink.evaluateFunction(fnName, parseArgs(fnArgs), true); 44 | setOutputMsg(result); 45 | setErrorMsg(''); 46 | } catch (e) { 47 | setErrorMsg(e.toString()); 48 | } 49 | } 50 | }; 51 | 52 | return( 53 | 54 |
55 |
56 | : 57 |
58 |
59 | 60 |
61 |
62 |
63 |
64 | : 65 |
66 |
67 | 75 |
76 |
77 |
78 |
79 |
80 |
81 |
{fnName && `${fnName}(${fnArgs ? parseArgs(fnArgs).map(JSON.stringify).join(', ') : ''})`}
82 |
83 | {errorMsg &&
84 | {errorMsg} 85 |
} 86 |
87 | {outputMsg && {outputMsg.output}], 90 | ]} />} 91 | 92 | 93 | ); 94 | }; 95 | 96 | export default DebugFunctions; 97 | -------------------------------------------------------------------------------- /src/components/views/debugger/globaltags.jsx: -------------------------------------------------------------------------------- 1 | import { h } from 'preact'; 2 | import style from './index.module.css'; 3 | 4 | import { KNOWN_GLOBAL_TAGS } from 'src/constants'; 5 | 6 | import { useAtrament } from 'src/atrament/hooks'; 7 | import { useTranslator } from '@eo-locale/preact'; 8 | 9 | import Collapse from 'src/components/ui/collapse'; 10 | import Table from 'src/components/ui/table'; 11 | 12 | const DebugGlobaltags = () => { 13 | const { atrament } = useAtrament(); 14 | const translator = useTranslator(); 15 | const globaltags = atrament.ink.getGlobalTags(); 16 | 17 | const displayGlobaltags = Object.keys(globaltags).map(k => { 18 | if (KNOWN_GLOBAL_TAGS.includes(k)) { 19 | return [k, globaltags[k]]; 20 | } 21 | return [( 22 | {k}), globaltags[k]]; 27 | }); 28 | 29 | return( 30 | 31 |
32 | 33 | ); 34 | }; 35 | 36 | export default DebugGlobaltags; 37 | -------------------------------------------------------------------------------- /src/components/views/debugger/goto.jsx: -------------------------------------------------------------------------------- 1 | import { h } from 'preact'; 2 | import style from './index.module.css'; 3 | import { Text } from '@eo-locale/preact'; 4 | import { useState } from 'preact/hooks'; 5 | import { useAtrament } from 'src/atrament/hooks'; 6 | 7 | const DebugGoto = ({closeFn}) => { 8 | const { atrament } = useAtrament(); 9 | const [ pathString, setPathString ] = useState(''); 10 | const [ errorMsg, setErrorMsg ] = useState(''); 11 | 12 | const handlePathstringChange = (e) => { 13 | setPathString(e.target.value); 14 | setErrorMsg(''); 15 | }; 16 | const goToPath = () => { 17 | if (pathString) { 18 | try { 19 | atrament.ink.goTo(pathString); 20 | atrament.game.continueStory(); 21 | closeFn(); 22 | } catch (e) { 23 | setErrorMsg(e.toString()); 24 | } 25 | setPathString(''); 26 | } 27 | }; 28 | 29 | return( 30 | <> 31 |
32 |
33 | : 34 |
35 |
36 | 37 |
38 |
39 |
40 |
41 |
42 |
43 | {errorMsg} 44 |
45 | 46 | ); 47 | }; 48 | 49 | export default DebugGoto; 50 | -------------------------------------------------------------------------------- /src/components/views/debugger/index.jsx: -------------------------------------------------------------------------------- 1 | import { h } from 'preact'; 2 | import { useEffect, useCallback, useState } from 'preact/hooks'; 3 | import { useTranslator } from '@eo-locale/preact'; 4 | import style from './index.module.css'; 5 | 6 | import { useAtramentState } from 'src/atrament/hooks'; 7 | import CloseButton from 'src/components/ui/close-button'; 8 | import { IconDebugger } from 'src/components/ui/icons'; 9 | 10 | import DebugInfo from './info'; 11 | import DebugGoto from './goto'; 12 | import DebugGlobaltags from './globaltags'; 13 | import DebugVariables from './variables'; 14 | import DebugVisits from './visits'; 15 | import DebugFunctions from './functions'; 16 | 17 | const DebuggerView = () => { 18 | const [ isOpen, openDebugger ] = useState(false); 19 | const [ keyWait, setKeyWait ] = useState(false); 20 | const translator = useTranslator(); 21 | 22 | const toggleDebugger = useCallback(() => openDebugger(!isOpen), [ isOpen ]); 23 | 24 | const debugHandler = useCallback((e) => { 25 | if (e.code === 'Backquote') { 26 | if (keyWait) { 27 | toggleDebugger(); 28 | setKeyWait(false); 29 | } else { 30 | setKeyWait(true); 31 | setTimeout(() => setKeyWait(false), 1000); 32 | } 33 | } 34 | }, [ toggleDebugger, keyWait, setKeyWait ]); 35 | 36 | useEffect(() => { 37 | document.addEventListener("keydown", debugHandler, false); 38 | return () => { 39 | document.removeEventListener("keydown", debugHandler, false); 40 | } 41 | }, [ debugHandler ]); 42 | 43 | if (!isOpen) { 44 | return (); 45 | } 46 | 47 | return ( 48 |
49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 |
57 | ); 58 | } 59 | 60 | const DebuggerMenu = () => { 61 | const { metadata } = useAtramentState(['metadata']); 62 | return metadata.debug ? : <>; 63 | } 64 | 65 | export default DebuggerMenu; 66 | -------------------------------------------------------------------------------- /src/components/views/debugger/index.module.css: -------------------------------------------------------------------------------- 1 | .debug_toggle { 2 | position: absolute; 3 | top: 3em; 4 | right: 0.6em; 5 | padding: 0.5rem; 6 | width: 2em; 7 | cursor: pointer; 8 | border: none; 9 | border-radius: var(--border-radius); 10 | background-color: var(--shade-color); 11 | color: var(--accent-fg-color); 12 | z-index: 300; 13 | } 14 | 15 | .debug_container { 16 | position: absolute; 17 | display: flex; 18 | flex-direction: column; 19 | justify-content: flex-start; 20 | width: 100%; 21 | height: 100%; 22 | background: var(--bg-color); 23 | border: 5px solid var(--accent-fg-color); 24 | padding: 0.5rem; 25 | z-index: 1500; 26 | gap: 1em; 27 | overflow: auto; 28 | } 29 | 30 | .container { 31 | display: flex; 32 | flex-direction: row; 33 | align-items: center; 34 | width: 100%; 35 | } 36 | 37 | .container div { 38 | vertical-align: middle; 39 | padding: 0.2rem; 40 | } 41 | 42 | .container pre { 43 | margin-bottom: 0; 44 | } 45 | 46 | .input_div { 47 | flex-grow: 1; 48 | } 49 | 50 | .button { 51 | background-color: var(--bg-color); 52 | color: var(--fg-color); 53 | text-decoration: none; 54 | cursor: pointer; 55 | padding: 0.25rem; 56 | border: 2px solid var(--fg-color); 57 | border-radius: var(--border-radius-inline); 58 | } 59 | 60 | .button:hover { 61 | background-color: var(--fg-color); 62 | color: var(--bg-color); 63 | } 64 | 65 | .input { 66 | width: 100%; 67 | padding: 0.25rem; 68 | border: 2px solid var(--fg-color); 69 | border-radius: var(--border-radius-inline); 70 | background-color: var(--bg-color); 71 | color: var(--fg-color); 72 | } 73 | 74 | .errormsg { 75 | color: var(--accent-fg-color); 76 | } 77 | 78 | .outputmsg { 79 | font-family: monospace; 80 | } 81 | 82 | .unknown_tag { 83 | color: var(--accent-fg-color); 84 | font-weight: bold; 85 | cursor: help; 86 | } 87 | -------------------------------------------------------------------------------- /src/components/views/debugger/info.jsx: -------------------------------------------------------------------------------- 1 | import { h } from 'preact'; 2 | import { useAtrament } from 'src/atrament/hooks'; 3 | import { useTranslator } from '@eo-locale/preact'; 4 | 5 | import Collapse from 'src/components/ui/collapse'; 6 | import Table from 'src/components/ui/table'; 7 | 8 | const DebugInfo = () => { 9 | const { atrament } = useAtrament(); 10 | const translator = useTranslator(); 11 | 12 | const inkstory = atrament.ink.story(); 13 | const inkstate = inkstory.state; 14 | const gamedata = atrament.state.get().game; 15 | 16 | const tableData = [ 17 | [translator.translate('debug.info.ink-file'), `${gamedata.$path}/${gamedata.$file}`], 18 | [translator.translate('debug.info.story-seed'), inkstate.storySeed], 19 | [translator.translate('debug.info.current-turn-index'), inkstate.currentTurnIndex], 20 | [translator.translate('debug.info.path'), gamedata.$story_path], 21 | ]; 22 | 23 | return( 24 | 25 |
26 | 27 | ); 28 | }; 29 | 30 | export default DebugInfo; -------------------------------------------------------------------------------- /src/components/views/debugger/var-editor.jsx: -------------------------------------------------------------------------------- 1 | import { h } from 'preact'; 2 | import style from './var-editor.module.css'; 3 | import { useEffect, useState } from 'preact/hooks'; 4 | import { useTranslator } from '@eo-locale/preact'; 5 | import { useAtrament } from 'src/atrament/hooks'; 6 | 7 | import Toggle from 'src/components/ui/toggle'; 8 | 9 | const NumberEditor = ({value, setNewValue}) => { 10 | const updateValue = (e) => setNewValue(+e.target.value); 11 | return (); 12 | }; 13 | 14 | const StringEditor = ({value, setNewValue}) => { 15 | const updateValue = (e) => setNewValue(e.target.value); 16 | return (); 17 | }; 18 | 19 | const BooleanEditor = ({value, setNewValue}) => { 20 | const updateValue = (e) => setNewValue(e.target.checked); 21 | return (
); 22 | }; 23 | 24 | const ListViewer = ({name, value}) => { 25 | if (name === value._originNames[0] && value.size === 0) { 26 | // empty list, show only possible keys 27 | const mapKeys = [...value.origins[0]._itemNameToValues.keys()]; 28 | return ( 29 | {JSON.stringify(mapKeys).replace(/,/g, ', ')} 30 | ); 31 | } 32 | const arr = []; 33 | value.forEach((v, k) => arr.push([JSON.parse(k), v])); 34 | return ( 35 |
    36 | {arr.map(([k, v]) =>
  • {k.itemName}: {v}
  • )} 37 |
38 | ); 39 | } 40 | 41 | 42 | 43 | const DebugVariableEditor = ({name, value}) => { 44 | const { getInkVariable, setInkVariable } = useAtrament(); 45 | const translator = useTranslator(); 46 | const [ editMode, setEditMode ] = useState(false); 47 | const [ initialValue, setInitialValue ] = useState(false); 48 | const [ newValue, setNewValue ] = useState(false); 49 | 50 | useEffect(() => { 51 | const v = getInkVariable(name); 52 | setInitialValue(v); 53 | setNewValue(v); 54 | }, [ name, getInkVariable, setInitialValue, setNewValue ]); 55 | 56 | const handleEdit = (e) => { 57 | e.preventDefault(); 58 | setEditMode(true); 59 | }; 60 | 61 | const cancelEdit = () => setEditMode(false); 62 | 63 | const saveValue = () => { 64 | if (newValue !== initialValue) { 65 | setInkVariable(name, newValue); 66 | setInitialValue(newValue); 67 | } 68 | setEditMode(false); 69 | } 70 | 71 | return ( 72 | editMode ? 73 | <> 74 | 75 | {typeof value === 'number' && } 76 | {typeof value === 'string' && } 77 | {typeof value === 'boolean' && } 78 | 79 | 80 | : 81 | <> 82 | {typeof newValue === 'object' 83 | ? 84 | : 85 | } 86 | 87 | ); 88 | } 89 | 90 | export default DebugVariableEditor; 91 | -------------------------------------------------------------------------------- /src/components/views/debugger/var-editor.module.css: -------------------------------------------------------------------------------- 1 | .button { 2 | background-color: var(--bg-color); 3 | color: var(--fg-color); 4 | text-decoration: none; 5 | font-weight: bold; 6 | cursor: pointer; 7 | padding: 0.25rem; 8 | border: 1px solid var(--fg-color); 9 | border-radius: var(--border-radius-inline); 10 | } 11 | 12 | .button:hover { 13 | background-color: var(--fg-color); 14 | color: var(--bg-color); 15 | } 16 | 17 | .button_close { 18 | background-color: var(--bg-color); 19 | color: var(--accent-fg-color); 20 | text-decoration: none; 21 | font-weight: bold; 22 | cursor: pointer; 23 | padding: 0.25rem; 24 | border: 1px solid var(--fg-color); 25 | border-radius: var(--border-radius-inline); 26 | } 27 | 28 | .button_close:hover { 29 | background-color: var(--accent-fg-color); 30 | color: var(--accent-bg-color); 31 | } 32 | 33 | .input { 34 | width: 10rem; 35 | padding: 0.25rem; 36 | border: 1px solid var(--fg-color); 37 | border-radius: var(--border-radius-inline); 38 | background-color: var(--bg-color); 39 | color: var(--fg-color); 40 | } 41 | 42 | .checkbox { 43 | width: 2rem; 44 | } 45 | 46 | .toggle { 47 | display: inline-block; 48 | margin-left: 1rem; 49 | margin-right: 1rem; 50 | } 51 | 52 | .listview { 53 | font-family: monospace; 54 | } -------------------------------------------------------------------------------- /src/components/views/debugger/variables.jsx: -------------------------------------------------------------------------------- 1 | import { h } from 'preact'; 2 | import style from './index.module.css'; 3 | import { useState } from 'preact/hooks'; 4 | import { useAtrament } from 'src/atrament/hooks'; 5 | import { useTranslator } from '@eo-locale/preact'; 6 | 7 | import Collapse from 'src/components/ui/collapse'; 8 | import Table from 'src/components/ui/table'; 9 | 10 | import DebugVariableEditor from './var-editor'; 11 | 12 | function listInkVariables(atrament) { 13 | const varState = atrament.ink.getVariables(); 14 | const inkVariables = Object.entries(varState); 15 | return inkVariables.sort((a, b) => a[0] > b[0] ? 0 : -1); 16 | } 17 | 18 | const DebugVariablesTable = ({ variables }) => { 19 | const translator = useTranslator(); 20 | const tableData = variables.map( 21 | (item, i) => [ item[0], ] 22 | ); 23 | return( 24 |
30 | ); 31 | } 32 | 33 | const DebugVariables = () => { 34 | const [ varNameFilter, setVarNameFilter ] = useState(''); 35 | const { atrament } = useAtrament(); 36 | const translator = useTranslator(); 37 | const inkVariables = listInkVariables(atrament).filter((v) => v[0].includes(varNameFilter)); 38 | 39 | const handleFilterChange = (e) => { 40 | setVarNameFilter(e.target.value); 41 | }; 42 | 43 | return( 44 | 45 |
46 | 53 |
54 | 55 |
56 | ); 57 | }; 58 | 59 | export default DebugVariables; 60 | -------------------------------------------------------------------------------- /src/components/views/debugger/visits.jsx: -------------------------------------------------------------------------------- 1 | import { h } from 'preact'; 2 | import { useAtrament } from 'src/atrament/hooks'; 3 | import { useTranslator } from '@eo-locale/preact'; 4 | 5 | import Collapse from 'src/components/ui/collapse'; 6 | import Table from 'src/components/ui/table'; 7 | 8 | function listInkVisits(atrament) { 9 | const visitCounts = atrament.ink.story().state._visitCounts; 10 | const inkVisitCounts = []; 11 | for (let item of visitCounts) { 12 | inkVisitCounts.push(item); 13 | } 14 | return inkVisitCounts.sort((a, b) => a[0] > b[0] ? 0 : -1); 15 | } 16 | 17 | const DebugVisits = () => { 18 | const { atrament } = useAtrament(); 19 | const translator = useTranslator(); 20 | const inkVisits = listInkVisits(atrament); 21 | return( 22 | 23 |
24 | 25 | ); 26 | }; 27 | 28 | export default DebugVisits; 29 | -------------------------------------------------------------------------------- /src/components/views/home-menu/game-cover.jsx: -------------------------------------------------------------------------------- 1 | import { h } from 'preact'; 2 | import { useTranslator } from '@eo-locale/preact'; 3 | import { useAtrament, useAtramentState } from 'src/atrament/hooks'; 4 | 5 | import ContainerImage from 'src/components/ui/container-image'; 6 | import Header from 'src/components/ui/header'; 7 | 8 | const GameCover = () => { 9 | const { getAssetPath } = useAtrament(); 10 | const translator = useTranslator(); 11 | const atramentState = useAtramentState(['metadata']); 12 | const { title, author, cover } = atramentState.metadata; 13 | return ( 14 |
15 | {cover ? : ''} 16 |

{title ? title : translator.translate('default.title')}

17 |

{author ? author : translator.translate('default.author')}

18 |
19 | ) 20 | }; 21 | 22 | export default GameCover; 23 | -------------------------------------------------------------------------------- /src/components/views/home-menu/index.jsx: -------------------------------------------------------------------------------- 1 | import { h } from 'preact'; 2 | import { useEffect, useState } from 'preact/hooks'; 3 | import { route } from 'preact-router'; 4 | import { Text } from '@eo-locale/preact'; 5 | 6 | import { useAtrament, useAtramentState } from 'src/atrament/hooks'; 7 | 8 | import Block from 'src/components/ui/block'; 9 | import LinkMenu from 'src/components/ui/link-menu'; 10 | 11 | import SessionsView from 'src/components/views/sessions'; 12 | 13 | import LoadGameMenu from './load-game'; 14 | import GameCover from './game-cover'; 15 | import useGameControls from './use-game-controls'; 16 | 17 | 18 | const AboutGame = () => ( route('/about')}>); 19 | 20 | const MainMenu = ({ canBeResumed, canBeLoaded, openLoadGameMenu, about }) => { 21 | const { newGame, resumeGame } = useGameControls(); 22 | return ( 23 | <> 24 | 25 | 26 | {canBeResumed ? : ''} 27 | 28 | {canBeLoaded ? : ''} 29 | {about ? : ''} 30 | 31 | 32 | ); 33 | }; 34 | 35 | 36 | export const SessionsMenuView = () => { 37 | const [ loadGameMenuVisible, setLoadGameMenuVisible ] = useState(false); 38 | const { atrament, canResume } = useAtrament(); 39 | const { newGame, resumeGame } = useGameControls(); 40 | const { metadata } = useAtramentState(['metadata']); 41 | 42 | const openLoadGameMenu = () => setLoadGameMenuVisible(true); 43 | const closeLoadGameMenu = () => { 44 | atrament.game.setSession(); 45 | setLoadGameMenuVisible(false); 46 | } 47 | 48 | return ( 49 | <> 50 | {loadGameMenuVisible 51 | ? 52 | : <> 53 | 54 | 55 | 56 | {metadata.about ? : ''} 57 | 58 | 59 | } 60 | 61 | ); 62 | }; 63 | 64 | 65 | export const HomeMenuView = () => { 66 | const [ loadGameMenuVisible, setLoadGameMenuVisible ] = useState(false); 67 | const [ canBeResumed, setResumeState ] = useState(false); 68 | const [ canBeLoaded, setLoadedState ] = useState(false); 69 | const { atrament, canResume } = useAtrament(); 70 | const { metadata } = useAtramentState(['metadata']); 71 | 72 | const openLoadGameMenu = () => setLoadGameMenuVisible(true); 73 | const closeLoadGameMenu = () => setLoadGameMenuVisible(false); 74 | 75 | useEffect(() => { 76 | const initHome = async () => { 77 | const canResumeGame = await canResume(); 78 | setResumeState(!!canResumeGame); 79 | const existingSaves = await atrament.game.listSaves(); 80 | const saves = existingSaves.filter( 81 | (s) => { 82 | if (metadata.load_from_checkpoints && s.type === atrament.game.SAVE_CHECKPOINT) { 83 | return true; 84 | } 85 | return s.type === atrament.game.SAVE_GAME; 86 | } 87 | ); 88 | if (saves.length) { 89 | setLoadedState(true); 90 | } 91 | } 92 | initHome(); 93 | }, [ atrament.game, canResume, metadata, setLoadedState ]); 94 | 95 | return ( 96 | <> 97 | {loadGameMenuVisible 98 | ? 99 | : 100 | } 101 | 102 | ); 103 | }; 104 | 105 | -------------------------------------------------------------------------------- /src/components/views/home-menu/load-game.jsx: -------------------------------------------------------------------------------- 1 | 2 | import { h } from 'preact'; 3 | import { Text } from '@eo-locale/preact'; 4 | 5 | import Block from 'src/components/ui/block'; 6 | import Header from 'src/components/ui/header'; 7 | import Break from 'src/components/ui/break'; 8 | 9 | import LoadGameView from 'src/components/views/loadgame'; 10 | 11 | import useGameControls from './use-game-controls'; 12 | 13 | const LoadGameMenu = ({ children }) => { 14 | const { loadGame } = useGameControls(); 15 | return ( 16 | 17 |

18 | 19 | 20 | {children} 21 |
22 | ); 23 | }; 24 | 25 | export default LoadGameMenu; 26 | -------------------------------------------------------------------------------- /src/components/views/home-menu/use-game-controls.js: -------------------------------------------------------------------------------- 1 | import { useCallback } from 'preact/hooks'; 2 | import { route } from 'preact-router'; 3 | import { useAtrament, useAtramentState } from 'src/atrament/hooks'; 4 | 5 | const useGameControls = () => { 6 | const { resetBackground, gameStart, gameResume, continueStory } = useAtrament(); 7 | const { metadata } = useAtramentState(['metadata']); 8 | 9 | const newGame = useCallback(async () => { 10 | resetBackground(); 11 | await gameStart(); 12 | continueStory(); 13 | route('/game'); 14 | }, [ resetBackground, gameStart, continueStory ]); 15 | 16 | const resumeGame = useCallback(async () => { 17 | resetBackground(); 18 | await gameResume(); 19 | if (metadata.continue_maximally !== false) { 20 | continueStory(); 21 | } 22 | route('/game'); 23 | }, [ resetBackground, gameResume, continueStory, metadata ]); 24 | 25 | const loadGame = useCallback(async (saveslot) => { 26 | resetBackground(); 27 | await gameStart(saveslot); 28 | if (metadata.continue_maximally !== false) { 29 | continueStory(); 30 | } 31 | route('/game'); 32 | }, [ resetBackground, gameStart, continueStory, metadata ]); 33 | 34 | return { newGame, resumeGame, loadGame }; 35 | }; 36 | 37 | export default useGameControls; 38 | -------------------------------------------------------------------------------- /src/components/views/loadgame/index.jsx: -------------------------------------------------------------------------------- 1 | import { h } from 'preact'; 2 | import { useTranslator } from '@eo-locale/preact'; 3 | import { useEffect, useState, useCallback } from 'preact/hooks'; 4 | 5 | import { appLocale } from 'src/constants'; 6 | import { useAtrament, useAtramentState } from 'src/atrament/hooks'; 7 | 8 | import MenuListItem from 'src/components/ui/menu-list-item'; 9 | 10 | const datefmt = (date) => new Date(date).toLocaleString(appLocale); 11 | 12 | 13 | const LoadGameView = ({ loadGame, hasConfirmation = false }) => { 14 | const [ saveslots, setSaveslots ] = useState([]); 15 | const translator = useTranslator(); 16 | const { atrament } = useAtrament(); 17 | const { metadata } = useAtramentState(['metadata']); 18 | 19 | const initLoadgame = useCallback(async () => { 20 | const existingSaves = await atrament.game.listSaves(); 21 | const saveSlotList = []; 22 | // autosave 23 | const autosave = existingSaves 24 | .filter((s) => s.type === atrament.game.SAVE_AUTOSAVE) 25 | .map((s) => ({ ...s, slot: translator.translate('main.autosave', { date: datefmt(s.date) }) })); 26 | if (autosave.length) { 27 | saveSlotList.push(autosave[0]); 28 | } 29 | // checkpoints 30 | if (metadata.load_from_checkpoints) { 31 | const checkpoints = existingSaves 32 | .filter((s) => s.type === atrament.game.SAVE_CHECKPOINT) 33 | .map((s) => ({ 34 | ...s, 35 | slot: typeof s.name === 'boolean' 36 | ? translator.translate('main.checkpoint') 37 | : translator.translate('main.checkpoint-named', { name: s.name }) 38 | })); 39 | if (checkpoints.length) { 40 | saveSlotList.push(...checkpoints); 41 | } 42 | } 43 | // saved games 44 | const saves = existingSaves 45 | .filter((s) => s.type === atrament.game.SAVE_GAME) 46 | .map((s) => ({ ...s, slot: datefmt(s.date) })); 47 | saveSlotList.push(...saves); 48 | // done 49 | setSaveslots(saveSlotList); 50 | }, [ atrament, metadata, translator ]); 51 | 52 | const startSavedGame = useCallback(async (ev) => { 53 | const chosenSaveslot = ev.target.getAttribute('data-save'); 54 | await loadGame(chosenSaveslot); 55 | }, [ loadGame ]); 56 | 57 | const deleteSavedGame = useCallback(async (ev) => { 58 | const chosenSaveslot = ev.target.getAttribute('data-save'); 59 | await atrament.game.removeSave(chosenSaveslot); 60 | await initLoadgame(); 61 | }, [ atrament, initLoadgame ]); 62 | 63 | useEffect(() => { 64 | const onLoad = async () => initLoadgame(); 65 | onLoad(); 66 | }, [ initLoadgame ]); 67 | 68 | return ( 69 | <> 70 | {saveslots.map((s) => 71 | 84 | {s.slot} 85 | 86 | )} 87 | 88 | ); 89 | }; 90 | 91 | export default LoadGameView; 92 | -------------------------------------------------------------------------------- /src/components/views/overlay/index.jsx: -------------------------------------------------------------------------------- 1 | import { h } from 'preact'; 2 | import style from './index.module.css'; 3 | 4 | import { IconToolbarBack } from 'src/components/ui/icons'; 5 | 6 | import { useAtramentOverlay } from 'src/atrament/hooks'; 7 | 8 | import ContainerText from 'src/components/ui/container-text'; 9 | import TextParagraph from 'src/components/ui/text-paragraph'; 10 | import Markup from 'src/components/ui/markup'; 11 | 12 | const OverlayView = () => { 13 | const { overlay, closeOverlay } = useAtramentOverlay(); 14 | 15 | if (!overlay.current) { 16 | return <>; 17 | } 18 | 19 | return ( 20 |
21 |
22 | 23 | {overlay.title &&
{overlay.title}
} 24 |
25 |
26 | 27 | {overlay.content.map((item, index) => )} 28 | 29 |
30 |
31 | ); 32 | } 33 | 34 | export default OverlayView; 35 | -------------------------------------------------------------------------------- /src/components/views/overlay/index.module.css: -------------------------------------------------------------------------------- 1 | .overlay_container { 2 | position: absolute; 3 | width: 100%; 4 | height: 100%; 5 | display: flex; 6 | flex-direction: column; 7 | background: var(--bg-color); 8 | z-index: 80; 9 | } 10 | 11 | .overlay_header { 12 | min-height: 2.5em; 13 | flex-grow: 0; 14 | flex-shrink: 0; 15 | flex-basis: auto; 16 | border-bottom: 1px solid var(--fg-color); 17 | display: flex; 18 | } 19 | 20 | .overlay_header > * { 21 | vertical-align: middle; 22 | } 23 | 24 | .overlay_title { 25 | flex-grow: 1; 26 | margin-right: 3rem; 27 | padding: 0.25rem; 28 | display: flex; 29 | align-items: center; 30 | justify-content: center; 31 | } 32 | 33 | .overlay_content { 34 | padding: 1rem; 35 | flex-grow: 1; 36 | overflow: auto; 37 | } 38 | 39 | .overlay_content p { 40 | margin-top: 0; 41 | } 42 | 43 | .overlay_content p ul { 44 | margin-top: 0; 45 | margin-bottom: 0; 46 | } 47 | 48 | .button_back { 49 | cursor: pointer; 50 | border: none; 51 | background: none; 52 | color: var(--font-color); 53 | flex-shrink: 0; 54 | width: 3rem; 55 | } 56 | -------------------------------------------------------------------------------- /src/components/views/savegame/index.jsx: -------------------------------------------------------------------------------- 1 | import { h } from 'preact'; 2 | import { useTranslator } from '@eo-locale/preact'; 3 | import { useEffect, useState, useCallback } from 'preact/hooks'; 4 | 5 | import { appLocale } from 'src/constants'; 6 | import { useAtrament, useAtramentState } from 'src/atrament/hooks'; 7 | 8 | import MenuListItem from 'src/components/ui/menu-list-item'; 9 | 10 | const datefmt = (date) => new Date(date).toLocaleString(appLocale); 11 | 12 | const SaveGameView = ({ saveGame }) => { 13 | const [ saveslots, setSaveslots ] = useState([]); 14 | const translator = useTranslator(); 15 | const { atrament } = useAtrament(); 16 | const { metadata } = useAtramentState(['metadata']); 17 | const numberOfSaveSlots = metadata.saves ? +metadata.saves : 0; 18 | 19 | const initSavegame = useCallback(async () => { 20 | const existingSaves = await atrament.game.listSaves(); 21 | // saved games 22 | const saves = existingSaves 23 | .filter((s) => s.type === atrament.game.SAVE_GAME) 24 | .map((s) => ({ ...s, slot: datefmt(s.date) })); 25 | if (saves.length < numberOfSaveSlots) { 26 | // new save is possible 27 | saves.push({ 28 | name: saves.length + 1, 29 | slot: translator.translate('main.new-save') 30 | }); 31 | } 32 | setSaveslots(saves); 33 | }, [ atrament, translator, numberOfSaveSlots ]); 34 | 35 | const saveGameToSlot = useCallback(async (ev) => { 36 | const chosenSaveslot = ev.target.getAttribute('data-savename'); 37 | await saveGame(chosenSaveslot); 38 | await initSavegame(); 39 | }, [ saveGame, initSavegame ]); 40 | 41 | const deleteSavedGame = useCallback(async (ev) => { 42 | const chosenSaveslot = ev.target.getAttribute('data-saveid'); 43 | await atrament.game.removeSave(chosenSaveslot); 44 | await initSavegame(); 45 | }, [ atrament, initSavegame ]); 46 | 47 | useEffect(() => { 48 | const onLoad = async () => initSavegame(); 49 | onLoad(); 50 | }, [ initSavegame ]); 51 | 52 | return ( 53 | <> 54 | {saveslots.map((s) => 55 | 69 | {s.slot} 70 | 71 | )} 72 | 73 | ); 74 | }; 75 | 76 | export default SaveGameView; 77 | -------------------------------------------------------------------------------- /src/components/views/sessions/index.jsx: -------------------------------------------------------------------------------- 1 | import { h } from 'preact'; 2 | import { Text, useTranslator } from '@eo-locale/preact'; 3 | import { useEffect, useState, useCallback } from 'preact/hooks'; 4 | 5 | import { useAtrament, useAtramentState } from 'src/atrament/hooks'; 6 | 7 | import MenuListItem from 'src/components/ui/menu-list-item'; 8 | 9 | 10 | const SessionsView = ({ newGame, loadGame, resumeGame, canResume }) => { 11 | const [ sessions, setSessions ] = useState([]); 12 | const translator = useTranslator(); 13 | const { atrament } = useAtrament(); 14 | const { metadata } = useAtramentState(['metadata']); 15 | 16 | const initSessions = useCallback(async () => { 17 | const existingSessions = await atrament.game.getSessions(); 18 | let firstEmptySlot = true; 19 | setSessions([...Array(+metadata.sessions).keys()].map((s) => { 20 | const id = s + 1; 21 | const name = `session${id}`; 22 | const hasSaves = !!existingSessions[`session${id}`]; 23 | const canStart = hasSaves || firstEmptySlot; 24 | if (!hasSaves && firstEmptySlot) { 25 | firstEmptySlot = false; 26 | } 27 | return { id, name, hasSaves, canStart }; 28 | })); 29 | }, [ atrament, metadata, setSessions ]); 30 | 31 | const startSession = useCallback(async (ev) => { 32 | const chosenSession = ev.target.getAttribute('data-session'); 33 | const sessionData = sessions.filter(item => item.name === chosenSession)[0]; 34 | if (!sessionData) { 35 | return; 36 | } 37 | atrament.game.setSession(chosenSession); 38 | if (sessionData.hasSaves) { 39 | if (await canResume()) { 40 | await resumeGame(); 41 | } else { 42 | await loadGame(); 43 | } 44 | } else { 45 | await newGame(); 46 | } 47 | }, [ atrament, sessions, newGame, canResume, resumeGame, loadGame ]); 48 | 49 | const deleteSession = useCallback(async (ev) => { 50 | const chosenSession = ev.target.getAttribute('data-session'); 51 | await atrament.game.removeSession(chosenSession); 52 | await initSessions(); 53 | }, [ atrament, initSessions ]); 54 | 55 | useEffect(() => { 56 | const onLoad = async () => initSessions(); 57 | onLoad(); 58 | }, [ initSessions ]); 59 | 60 | 61 | return ( 62 | <> 63 | {sessions.map((s) => 64 | 75 | {s.hasSaves 76 | ? 77 | : (s.canStart 78 | ? 79 | : 80 | )} 81 | 82 | )} 83 | 84 | ); 85 | }; 86 | 87 | export default SessionsView; 88 | -------------------------------------------------------------------------------- /src/components/views/settings/index.jsx: -------------------------------------------------------------------------------- 1 | import { h } from 'preact'; 2 | import style from './index.module.css'; 3 | import { useTranslator } from '@eo-locale/preact'; 4 | 5 | import Collapse from '../../ui/collapse'; 6 | 7 | import SettingsFullscreen from './settings-fullscreen'; 8 | import SettingsSound from './settings-sound'; 9 | import SettingsText from './settings-text'; 10 | import SettingsFont from './settings-font'; 11 | import SettingsTheme from './settings-theme'; 12 | import SettingsAnimation from './settings-animation'; 13 | 14 | const Settings = () => { 15 | const translator = useTranslator(); 16 | return ( 17 |
18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 |
27 | ); 28 | }; 29 | 30 | export default Settings; 31 | -------------------------------------------------------------------------------- /src/components/views/settings/index.module.css: -------------------------------------------------------------------------------- 1 | .settings_container { 2 | padding-top: 1em; 3 | display: flex; 4 | flex-direction: column; 5 | justify-content: flex-start; 6 | gap: 1em; 7 | } 8 | -------------------------------------------------------------------------------- /src/components/views/settings/settings-animation/index.jsx: -------------------------------------------------------------------------------- 1 | import { h } from 'preact'; 2 | import { Text } from '@eo-locale/preact'; 3 | import Toggle from 'src/components/ui/toggle'; 4 | 5 | import { useAtrament, useAtramentState } from 'src/atrament/hooks'; 6 | 7 | const SettingsAnimation = () => { 8 | const { updateSettings } = useAtrament(); 9 | const atramentState = useAtramentState(['settings']); 10 | const handleAnimation = (e) => updateSettings('animation', e.target.checked); 11 | 12 | return ( 13 |
14 | 15 |
16 | ); 17 | }; 18 | 19 | export default SettingsAnimation; 20 | -------------------------------------------------------------------------------- /src/components/views/settings/settings-font/index.jsx: -------------------------------------------------------------------------------- 1 | import { h } from 'preact'; 2 | import style from './index.module.css'; 3 | import { useAtrament, useAtramentState } from 'src/atrament/hooks'; 4 | 5 | import { fonts } from 'src/fonts'; 6 | 7 | const SettingsFont = () => { 8 | const { updateSettings } = useAtrament(); 9 | const atramentState = useAtramentState(['settings']); 10 | const handleFont = (e) => updateSettings('font', e.target.value); 11 | const font = atramentState.settings.font; 12 | return ( 13 |
14 | 19 |
20 | ); 21 | }; 22 | 23 | export default SettingsFont; 24 | -------------------------------------------------------------------------------- /src/components/views/settings/settings-font/index.module.css: -------------------------------------------------------------------------------- 1 | .settings_font_container { 2 | width: 100%; 3 | } 4 | 5 | .settings_font_container select { 6 | width: 100%; 7 | padding: 0.5rem; 8 | background-color: var(--bg-color); 9 | color: var(--fg-color); 10 | border: 1px solid var(--fg-color); 11 | border-radius: var(--border-radius); 12 | } 13 | -------------------------------------------------------------------------------- /src/components/views/settings/settings-fullscreen/index.jsx: -------------------------------------------------------------------------------- 1 | import { h } from 'preact'; 2 | import { Text } from '@eo-locale/preact'; 3 | import Toggle from 'src/components/ui/toggle'; 4 | 5 | import { useAtrament, useAtramentState } from 'src/atrament/hooks'; 6 | 7 | const SettingsFullscreen = () => { 8 | const { updateSettings } = useAtrament(); 9 | const atramentState = useAtramentState(['settings']); 10 | const handleFullscreen = (e) => updateSettings('fullscreen', e.target.checked); 11 | 12 | return ( 13 |
14 | 15 |
16 | ); 17 | }; 18 | 19 | export default SettingsFullscreen; 20 | -------------------------------------------------------------------------------- /src/components/views/settings/settings-sound/index.jsx: -------------------------------------------------------------------------------- 1 | import { h } from 'preact'; 2 | import { Text } from '@eo-locale/preact'; 3 | import style from './index.module.css'; 4 | import Toggle from 'src/components/ui/toggle'; 5 | 6 | import { IconVolumeLow, IconVolumeHigh } from 'src/components/ui/icons'; 7 | 8 | import { useAtrament, useAtramentState } from 'src/atrament/hooks'; 9 | 10 | const SettingsSound = () => { 11 | const { updateSettings } = useAtrament(); 12 | const atramentState = useAtramentState(['settings']); 13 | const handleMute = (e) => updateSettings('mute', !e.target.checked); 14 | const handleVolume = (e) => updateSettings('volume', e.target.value); 15 | const { mute, volume } = atramentState.settings; 16 | 17 | return ( 18 | <> 19 |
20 | 21 |
22 |
23 |
24 |
25 | 34 |
35 |
36 |
37 | 38 | ); 39 | }; 40 | 41 | export default SettingsSound; 42 | -------------------------------------------------------------------------------- /src/components/views/settings/settings-sound/index.module.css: -------------------------------------------------------------------------------- 1 | .settings_sound_container { 2 | display: flex; 3 | flex-direction: row; 4 | } 5 | 6 | .settings_sound_input_container { 7 | flex-grow: 1; 8 | } 9 | 10 | .settings_sound_volume { 11 | width: 100%; 12 | margin-top: 0.1rem; 13 | vertical-align: top; 14 | accent-color: var(--fg-color); 15 | } 16 | 17 | .settings_sound_icon { 18 | padding-left: 0.5rem; 19 | padding-right: 0.5rem; 20 | height: 1.2rem; 21 | min-width: 2.5rem; 22 | width: 2.5rem; 23 | } 24 | -------------------------------------------------------------------------------- /src/components/views/settings/settings-text/index.jsx: -------------------------------------------------------------------------------- 1 | import { h } from 'preact'; 2 | import { Text } from '@eo-locale/preact'; 3 | import style from './index.module.css'; 4 | import { useAtrament, useAtramentState } from 'src/atrament/hooks'; 5 | 6 | import { defaultFontSize, stepFontSize, minFontSize, maxFontSize } from 'src/constants'; 7 | 8 | import { fonts } from 'src/fonts'; 9 | 10 | const datapointsFontSize = []; 11 | for (let s=minFontSize; s <= maxFontSize; s+= stepFontSize) { 12 | datapointsFontSize.push(s); 13 | } 14 | 15 | const SettingsText = () => { 16 | const { updateSettings } = useAtrament(); 17 | const atramentState = useAtramentState(['settings']); 18 | const { font, fontSize } = atramentState.settings; 19 | 20 | const handleFontSize = (e) => updateSettings('fontSize', e.target.value); 21 | 22 | return ( 23 |
24 |
25 |
A
26 |
27 | 37 | 38 | {datapointsFontSize.map((f) => 40 |
41 |
A
42 |
43 |
44 | 45 |
46 |
47 | ); 48 | }; 49 | 50 | export default SettingsText; 51 | -------------------------------------------------------------------------------- /src/components/views/settings/settings-text/index.module.css: -------------------------------------------------------------------------------- 1 | .settings_fontsize_container { 2 | display: flex; 3 | flex-direction: row; 4 | } 5 | 6 | .settings_font_a { 7 | margin: auto; 8 | min-width: 2rem; 9 | text-align: center; 10 | } 11 | 12 | .settings_fontsize_input_container { 13 | flex-grow: 1; 14 | } 15 | 16 | .settings_fontsize_input { 17 | width: 100%; 18 | margin-top: 0.5rem; 19 | accent-color: var(--fg-color); 20 | } 21 | 22 | .settings_font_sample { 23 | height: 9rem; 24 | padding: 0.5rem; 25 | line-height: calc(1ex / 0.32); 26 | overflow: hidden; 27 | } 28 | -------------------------------------------------------------------------------- /src/components/views/settings/settings-theme/index.jsx: -------------------------------------------------------------------------------- 1 | import { h } from 'preact'; 2 | import { Text } from '@eo-locale/preact'; 3 | import style from './index.module.css'; 4 | import { useAtrament } from 'src/atrament/hooks'; 5 | 6 | import { themes } from 'src/themes'; 7 | 8 | const SettingButton = ({themeId, themeConfig, onClick }) => { 9 | const buttonStyle={ 10 | color: themeConfig['fg-color'], 11 | 'background-color': themeConfig['bg-color'] 12 | } 13 | return ( 14 | 17 | ); 18 | }; 19 | 20 | const SettingsTheme = () => { 21 | const { updateSettings } = useAtrament(); 22 | const handleTheme = (e) => updateSettings('theme', e.target.attributes['data-theme-id'].value); 23 | return ( 24 |
25 | {Object.entries(themes).map(([k, v]) => { 26 | return 27 | })} 28 |
29 | ); 30 | }; 31 | 32 | export default SettingsTheme; 33 | -------------------------------------------------------------------------------- /src/components/views/settings/settings-theme/index.module.css: -------------------------------------------------------------------------------- 1 | .settings_theme_container { 2 | display: flex; 3 | flex-direction: row; 4 | flex-wrap: wrap; 5 | gap: 0.5rem; 6 | } 7 | 8 | .settings_theme_button { 9 | display: block; 10 | padding: 0.75em; 11 | flex: 20%; 12 | text-align: center; 13 | text-decoration: none; 14 | border: 1px solid var(--fg-color); 15 | border-radius: var(--border-radius); 16 | cursor: pointer; 17 | } 18 | -------------------------------------------------------------------------------- /src/components/views/story-error/index.jsx: -------------------------------------------------------------------------------- 1 | import { h } from 'preact'; 2 | import { useEffect, useCallback } from 'preact/hooks'; 3 | import { ERROR_STORE_KEY } from 'src/constants'; 4 | import { useAtrament, useAtramentState } from 'src/atrament/hooks'; 5 | 6 | import ErrorModal from 'src/components/ui/error-modal'; 7 | 8 | const StoryError = () => { 9 | const { atrament } = useAtrament(); 10 | const atramentState = useAtramentState([ERROR_STORE_KEY]); 11 | 12 | const closeModal = useCallback(() => atrament.state.setKey(ERROR_STORE_KEY, null), [ atrament ]); 13 | 14 | const escHandler = useCallback((e) => { 15 | if (e.key === "Escape") { 16 | closeModal() 17 | } 18 | }, [ closeModal ]); 19 | 20 | useEffect(() => { 21 | document.addEventListener("keydown", escHandler, false); 22 | return () => { 23 | document.removeEventListener("keydown", escHandler, false); 24 | } 25 | }, [ escHandler ]); 26 | 27 | if (atramentState[ERROR_STORE_KEY]) { 28 | return ; 29 | } 30 | }; 31 | 32 | export default StoryError; -------------------------------------------------------------------------------- /src/components/views/story/choice-button-group/index.jsx: -------------------------------------------------------------------------------- 1 | import { h } from 'preact'; 2 | import { useState, useCallback, useEffect } from 'preact/hooks'; 3 | import { useAtrament } from 'src/atrament/hooks'; 4 | import ChoiceButton from '../choice-button'; 5 | 6 | const ChoiceButtonGroup = ({ key, currentScene, setReady }) => { 7 | const { makeChoice, continueStory } = useAtrament(); 8 | const [ chosen, setChosen ] = useState(null); 9 | 10 | const numberOfChoices = (currentScene && currentScene.choices) ? currentScene.choices.length : -1; 11 | 12 | const selectChoice = useCallback((id) => { 13 | const delay = numberOfChoices > 1 ? 350 : 150; 14 | setChosen(id); 15 | setTimeout(() => { 16 | // pass choice to Atrament 17 | setTimeout(() => { 18 | setChosen(null); 19 | setReady(false); 20 | makeChoice(id); 21 | continueStory(); 22 | }, delay); 23 | }, 0); 24 | }, [ makeChoice, continueStory, setReady, numberOfChoices ]); 25 | 26 | const kbdChoiceHandler = useCallback((e) => { 27 | const kbdChoice = +e.key; 28 | const targetElement = e.target.tagName.toLowerCase(); 29 | if (targetElement === 'input') { 30 | // ignore keyboard event 31 | return; 32 | } 33 | if ( 34 | numberOfChoices > 0 && 35 | kbdChoice > 0 && 36 | kbdChoice <= numberOfChoices && 37 | !currentScene.choices[kbdChoice-1].disabled 38 | ) { 39 | selectChoice(currentScene.choices[kbdChoice-1].id); 40 | } 41 | }, [ numberOfChoices, selectChoice, currentScene.choices ]); 42 | 43 | useEffect(() => { 44 | document.addEventListener("keydown", kbdChoiceHandler, false); 45 | return () => { 46 | document.removeEventListener("keydown", kbdChoiceHandler, false); 47 | } 48 | }, [ kbdChoiceHandler ]); 49 | 50 | return ( 51 | <> 52 | {currentScene.choices.map((choice, index) => ( 53 | )) 59 | } 60 | 61 | ) 62 | }; 63 | 64 | export default ChoiceButtonGroup; 65 | -------------------------------------------------------------------------------- /src/components/views/story/choice-button/index.jsx: -------------------------------------------------------------------------------- 1 | import { h } from 'preact'; 2 | import style from './index.module.css'; 3 | 4 | import { useAtramentState } from 'src/atrament/hooks'; 5 | import Markup from 'src/components/ui/markup'; 6 | 7 | const ChoiceButton = ({ choice, chosen, handleClick }) => { 8 | const { metadata } = useAtramentState(['metadata']); 9 | 10 | const choiceIsMade = chosen !== null; // something is chosen 11 | const activeChoice = chosen === choice.id; // this is the active choice 12 | const onClick = () => { 13 | if (!choiceIsMade) { 14 | handleClick(choice.id); 15 | } 16 | }; 17 | 18 | const choiceStateClass = choiceIsMade ? (activeChoice ? style.choice_active : style.choice_inactive) : ''; 19 | const choiceGroupStyle = metadata.choices?.includes('grouped') ? style.buttons_grouped : style.buttons_separate; 20 | const choiceAlignment = metadata.choices?.includes('left') ? style.left_aligned : metadata.choices?.includes('right') ? style.right_aligned : ''; 21 | 22 | 23 | let choiceCustomClass = choice.tags.CLASS; 24 | if (Array.isArray(choiceCustomClass)) { 25 | choiceCustomClass = choiceCustomClass.join(' '); 26 | } 27 | 28 | const elementClasses = [style.choice_button, choiceGroupStyle, choiceAlignment, choiceStateClass, choiceCustomClass ]; 29 | 30 | return ( 31 | 39 | ); 40 | }; 41 | 42 | export default ChoiceButton; 43 | -------------------------------------------------------------------------------- /src/components/views/story/choice-button/index.module.css: -------------------------------------------------------------------------------- 1 | .choice_button { 2 | display: block; 3 | background-color: transparent; 4 | color: var(--fg-color); 5 | margin-top: 0.5em; 6 | padding: 0.75em; 7 | text-align: center; 8 | text-decoration: none; 9 | width: 100%; 10 | cursor: pointer; 11 | } 12 | 13 | .choice_button:hover { 14 | background-color: var(--fg-color); 15 | color: var(--bg-color); 16 | } 17 | 18 | .choice_button:disabled { 19 | background-color: var(--shade-color); 20 | color: var(--fg-color); 21 | opacity: 0.6; 22 | cursor: inherit; 23 | } 24 | 25 | .choice_active { 26 | background-color: var(--fg-color); 27 | border: 1px solid var(--fg-color); 28 | color: var(--bg-color); 29 | } 30 | 31 | .choice_active:hover { 32 | background-color: var(--fg-color); 33 | border: 1px solid var(--fg-color); 34 | color: var(--bg-color); 35 | } 36 | 37 | @keyframes disappear { 38 | 0% { 39 | opacity: 1; 40 | } 41 | 90% { 42 | opacity: 0.3; 43 | } 44 | 100% { 45 | opacity: 0; 46 | } 47 | } 48 | 49 | .choice_inactive { 50 | filter: blur(4px); 51 | animation-name: disappear; 52 | animation-duration: var(--animation-disabled, 0.2s); 53 | animation-delay: 0s; 54 | animation-iteration-count: 1; 55 | animation-fill-mode: forwards; 56 | } 57 | 58 | .choice_inactive:hover { 59 | background-color: var(--bg-color); 60 | color: var(--fg-color); 61 | } 62 | 63 | .choice_active:disabled:hover { 64 | background-color: var(--shade-color); 65 | color: var(--fg-color); 66 | } 67 | 68 | 69 | /* choice buttons - separate */ 70 | 71 | .buttons_separate { 72 | border: 1px solid var(--fg-color); 73 | border-radius: var(--border-radius); 74 | } 75 | 76 | /* choice buttons - block-like */ 77 | 78 | .buttons_grouped { 79 | margin-top: 0; 80 | border: 1px solid var(--fg-color); 81 | border-bottom: none; 82 | } 83 | 84 | .buttons_grouped:first-child { 85 | margin-top: 0.5em; 86 | border-radius: var(--border-radius) var(--border-radius) 0 0; 87 | border-bottom: none; 88 | } 89 | 90 | .buttons_grouped:last-child { 91 | border-radius: 0 0 var(--border-radius) var(--border-radius); 92 | border-bottom: 1px solid var(--fg-color); 93 | } 94 | 95 | .buttons_grouped:only-child { 96 | border-radius: var(--border-radius); 97 | } 98 | 99 | 100 | .left_aligned { 101 | text-align: left; 102 | } 103 | 104 | .right_aligned { 105 | text-align: right; 106 | } -------------------------------------------------------------------------------- /src/components/views/story/choices/index.jsx: -------------------------------------------------------------------------------- 1 | import { h } from 'preact'; 2 | import { Text } from '@eo-locale/preact'; 3 | import { route } from 'preact-router'; 4 | import { useAtrament } from 'src/atrament/hooks'; 5 | import LinkHome from 'src/components/ui/link-home'; 6 | import ChoiceButtonGroup from '../choice-button-group'; 7 | import ClickToContinue from '../click-to-continue'; 8 | 9 | import getTagAttributes from 'src/utils/get-tag-attributes'; 10 | 11 | function clickToContinueOptions(param) { 12 | const options = { 13 | delay: 0, 14 | animation: 0, 15 | clickable: 0 16 | }; 17 | if (param.startsWith('(')) { 18 | const attrs = getTagAttributes(param.replace(/[()]/g, '')); 19 | Object.entries(attrs).forEach(([k,v]) => options[k] = v); 20 | // alternate syntax 21 | if (options.continue) { 22 | options.delay = options.continue; 23 | } 24 | } else if (param) { 25 | options.delay = param; 26 | } else { 27 | options.animation = 3; // default animation pause 28 | } 29 | return options; 30 | } 31 | 32 | 33 | const EndGameLink = () => { 34 | const { atrament } = useAtrament(); 35 | const endGame = async () => { 36 | await atrament.game.removeSave(); 37 | route('/'); 38 | }; 39 | return (); 40 | }; 41 | 42 | const Choices = ({ key, currentScene, setReady, isHypertextMode }) => { 43 | const numberOfChoices = (currentScene && currentScene.choices) ? currentScene.choices.length : -1; 44 | if (numberOfChoices === 1) { 45 | const isClickToContinue = currentScene.choices[0].choice.match(/^(|>>>(\d*|\(.+?\)))$/); 46 | if (isClickToContinue) { 47 | // click-to-continue choice 48 | const options = clickToContinueOptions(isClickToContinue[2]); 49 | return (); 56 | } 57 | } else if (numberOfChoices === 0) { 58 | if (currentScene.canContinue) { 59 | // paragraph mode 60 | return (); 61 | } 62 | // end game 63 | return (); 64 | } 65 | if (isHypertextMode) { 66 | return <>; 67 | } 68 | return ( 69 | 74 | ); 75 | }; 76 | 77 | export default Choices; 78 | -------------------------------------------------------------------------------- /src/components/views/story/click-to-continue/index.jsx: -------------------------------------------------------------------------------- 1 | import { h } from 'preact'; 2 | import style from './index.module.css'; 3 | import { useCallback, useEffect, useState } from 'preact/hooks'; 4 | import { useTranslator } from '@eo-locale/preact'; 5 | import { useAtrament } from 'src/atrament/hooks'; 6 | 7 | const getSceneElement = () => document.getElementsByClassName('atrament-container-scene')[0]; 8 | const getChoicesElement = () => document.getElementsByClassName('atrament-container-choices')[0]; 9 | 10 | // options 11 | // clickable: pause before "click-to-continue" is allowed (0 - immediately) 12 | // animation: pause before animation is displayed (0 - immediately) 13 | // delay: pause before story continues (0 - only after click) 14 | 15 | const ClickToContinue = ({ setReady, withChoice = false, delay = 0, animation = 0, clickable = 0 }) => { 16 | const { makeChoice, continueStory } = useAtrament(); 17 | const [ isVisible, setIsVisible ] = useState(false); 18 | const translator = useTranslator(); 19 | 20 | const continueGame = useCallback(() => { 21 | setTimeout(() => { 22 | setReady(false); 23 | if (withChoice) { 24 | makeChoice(0); 25 | } 26 | continueStory(); 27 | }, 0); 28 | }, [ makeChoice, continueStory, setReady, withChoice ]); 29 | 30 | const kbdChoiceHandler = useCallback((e) => { 31 | if (e.key === ' ' || e.key === 'Enter') { 32 | continueGame(); 33 | } 34 | }, [ continueGame ]); 35 | 36 | const addEventListeners = useCallback(() => { 37 | document.addEventListener("keydown", kbdChoiceHandler, false); 38 | getSceneElement().addEventListener("click", continueGame, false); 39 | getChoicesElement().addEventListener("click", continueGame, false); 40 | return 0; 41 | }, [ continueGame, kbdChoiceHandler ]); 42 | 43 | const removeEventListeners = useCallback(() => { 44 | document.removeEventListener("keydown", kbdChoiceHandler, false); 45 | getSceneElement().removeEventListener("click", continueGame, false); 46 | getChoicesElement().removeEventListener("click", continueGame, false); 47 | return 0; 48 | }, [ continueGame, kbdChoiceHandler ]); 49 | 50 | useEffect(() => { 51 | const delayClickToContinue = setTimeout(addEventListeners, clickable * 1000); 52 | const delayAnimation = setTimeout(() => setIsVisible(true), animation * 1000); 53 | const delayChoice = delay ? setTimeout(continueGame, delay * 1000) : undefined; 54 | return () => { 55 | clearTimeout(delayAnimation); 56 | clearTimeout(delayClickToContinue); 57 | clearTimeout(delayChoice); 58 | removeEventListeners(); 59 | } 60 | }, [ addEventListeners, removeEventListeners, continueGame, setIsVisible, clickable, delay, animation ]); 61 | 62 | const elementStyles = [style.circle]; 63 | if (delay) { 64 | elementStyles.push(style.circle_empty); 65 | } 66 | 67 | return ( 68 |
69 | {isVisible 70 | ?
71 | : '' 72 | } 73 |
74 | ) 75 | }; 76 | 77 | export default ClickToContinue; -------------------------------------------------------------------------------- /src/components/views/story/click-to-continue/index.module.css: -------------------------------------------------------------------------------- 1 | @keyframes circle { 2 | from { 3 | transform: scale(0) 4 | } 5 | to { 6 | transform: scale(1) 7 | } 8 | } 9 | 10 | .container { 11 | position: relative; 12 | height: 3em; 13 | max-height: 3em; 14 | cursor: pointer; 15 | overflow: hidden; 16 | } 17 | 18 | .circle { 19 | position: absolute; 20 | top: 50%; 21 | left: 50%; 22 | transform: translate(-50%, -50%); /* center image */ 23 | display: flex; 24 | justify-content: center; 25 | align-items: center; 26 | width: 1.5em; 27 | height: 1.5em; 28 | background-color: var(--fg-color); 29 | border-radius: 50%; 30 | opacity: 0.3; 31 | } 32 | 33 | .circle:before, 34 | .circle:after { 35 | position: absolute; 36 | content: ""; 37 | top: 0; 38 | right: 0; 39 | bottom: 0; 40 | left: 0; 41 | border: solid 3px var(--fg-color); 42 | border-radius: 50%; 43 | } 44 | 45 | .circle:before { 46 | animation: ripple 4s linear infinite; 47 | } 48 | 49 | .circle:after { 50 | animation: ripple 2s linear infinite; 51 | } 52 | 53 | .circle_empty { 54 | background-color: inherit; 55 | } 56 | 57 | @keyframes ripple { 58 | to { 59 | transform: scale(2); 60 | opacity: 0; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/components/views/story/container-choices/index.jsx: -------------------------------------------------------------------------------- 1 | import { h } from 'preact'; 2 | import style from './index.module.css'; 3 | // UI 4 | import Block from 'src/components/ui/block'; 5 | 6 | const ContainerChoices = ({ children, key, isReady }) => { 7 | return ( 8 |
9 |
10 | 11 | {children} 12 | 13 |
14 |
15 | ) 16 | }; 17 | 18 | export default ContainerChoices; -------------------------------------------------------------------------------- /src/components/views/story/container-choices/index.module.css: -------------------------------------------------------------------------------- 1 | .container_choices { 2 | flex-grow: 0; 3 | } 4 | -------------------------------------------------------------------------------- /src/components/views/story/container-scenes/index.jsx: -------------------------------------------------------------------------------- 1 | import { h } from 'preact'; 2 | import style from './index.module.css'; 3 | 4 | const getAlign = (v) => { 5 | const alignments = { 6 | top: 'start', 7 | start: 'start', 8 | middle: 'center', 9 | bottom: 'end', 10 | end: 'end' 11 | }; 12 | return alignments[v] || 'center'; 13 | } 14 | 15 | const ContainerScenes = ({ children, align }) => { 16 | return ( 17 |
18 | {children} 19 |
20 | ); 21 | }; 22 | 23 | export default ContainerScenes; 24 | -------------------------------------------------------------------------------- /src/components/views/story/container-scenes/index.module.css: -------------------------------------------------------------------------------- 1 | .container_scenes { 2 | flex-grow: 1; 3 | display: flex; 4 | flex-direction: column; 5 | } 6 | -------------------------------------------------------------------------------- /src/components/views/story/index.jsx: -------------------------------------------------------------------------------- 1 | import { h } from 'preact'; 2 | import { useState } from 'preact/hooks'; 3 | 4 | import { useAtramentState } from 'src/atrament/hooks'; 5 | 6 | import ContainerText from 'src/components/ui/container-text'; 7 | import ContainerScenes from './container-scenes'; 8 | import ContainerChoices from './container-choices'; 9 | 10 | import Scene from './scene'; 11 | import Choices from './choices'; 12 | 13 | const StoryView = () => { 14 | const atramentState = useAtramentState(['scenes', 'metadata']); 15 | const [ isReady, setReady ] = useState(false); 16 | 17 | const lastSceneIndex = atramentState.scenes.length - 1; 18 | const isHypertextMode = !!atramentState.metadata.hypertext; 19 | const key = `choices-${atramentState.scenes[lastSceneIndex]?.uuid}`; 20 | 21 | return ( 22 | 23 | 24 | {atramentState.scenes.map((s, i) => 25 | 32 | )} 33 | 34 | 35 | 41 | 42 | 43 | ) 44 | } 45 | 46 | export default StoryView; 47 | -------------------------------------------------------------------------------- /src/components/views/story/scene-paragraph/index.jsx: -------------------------------------------------------------------------------- 1 | import { h } from 'preact'; 2 | import style from './index.module.css'; 3 | 4 | import Markup from 'src/components/ui/markup'; 5 | 6 | const Paragraph = ( {content, isCurrent} ) => { 7 | if (content.text === "\n") { 8 | return <>; 9 | } 10 | 11 | let pStyle; 12 | if (!isCurrent) { 13 | pStyle = {opacity: '70%'}; 14 | } 15 | 16 | let pClass = content.tags.CLASS || ''; 17 | if (Array.isArray(pClass)) { 18 | pClass = pClass.join(' '); 19 | } 20 | 21 | return ( 22 |
23 | 24 |
25 | ); 26 | } 27 | 28 | export default Paragraph; 29 | -------------------------------------------------------------------------------- /src/components/views/story/scene-paragraph/index.module.css: -------------------------------------------------------------------------------- 1 | .paragraph { 2 | display: block; 3 | margin-block-start: 1em; 4 | margin-block-end: 1em; 5 | margin-inline-start: 0; 6 | margin-inline-end: 0; 7 | unicode-bidi: isolate; 8 | } 9 | 10 | .paragraph:first-child { 11 | margin-block-start: 0; 12 | } 13 | 14 | .paragraph:last-child { 15 | margin-block-end: 0; 16 | } 17 | -------------------------------------------------------------------------------- /src/components/views/story/scene/index.jsx: -------------------------------------------------------------------------------- 1 | import { h, Fragment } from 'preact'; 2 | import style from './index.module.css'; 3 | import { useRef, useState, useEffect, useCallback } from 'preact/hooks'; 4 | import { scrollIntoView } from "seamless-scroll-polyfill"; 5 | // UI 6 | import ContainerImage from 'src/components/ui/container-image'; 7 | import Paragraph from '../scene-paragraph'; 8 | // utils 9 | import { useAtrament } from 'src/atrament/hooks'; 10 | import preloadImages from 'src/utils/preload-images'; 11 | 12 | const Scene = ({ scene, isCurrent, isSingle, readyHandler }) => { 13 | const { getAssetPath } = useAtrament(); 14 | const [ isLoaded, setIsLoaded ] = useState(false); 15 | const elementRef = useRef(null); 16 | 17 | const scrollToScene = useCallback((behavior) => { 18 | readyHandler(true); 19 | setTimeout( 20 | () => { 21 | // elementRef.current.scrollIntoView({ behavior, block: 'start' }); 22 | // native 'scrollIntoView' glitches sometimes, so we use polyfill instead 23 | scrollIntoView(elementRef.current, { behavior, block: 'start' }, { duration: 300 }); 24 | }, 25 | 100 26 | ); 27 | }, [ readyHandler ]); 28 | 29 | useEffect(() => { 30 | if (isLoaded && isCurrent) { 31 | scrollToScene(isSingle ? 'instant' : 'smooth'); 32 | } 33 | }, [isLoaded, isCurrent, isSingle, scrollToScene]); 34 | 35 | // preload all images for scene 36 | useEffect(() => { 37 | const preloader = async () => { 38 | await preloadImages(getAssetPath, scene.images); 39 | setIsLoaded(true); 40 | } 41 | preloader(); 42 | }, [ scene, setIsLoaded, getAssetPath ]); 43 | 44 | return ( 45 |
46 |
47 | { 48 | scene.content.map((item, i) => ( 49 | 50 | {item.images.map( 51 | (img, i) => 52 | )} 53 | 54 | 55 | )) 56 | } 57 |
58 |
59 | ) 60 | }; 61 | 62 | export default Scene; 63 | -------------------------------------------------------------------------------- /src/components/views/story/scene/index.module.css: -------------------------------------------------------------------------------- 1 | .scene { 2 | display: block; 3 | padding: 1rem; 4 | } 5 | 6 | .scene p:first-child { 7 | margin-top: 0px; 8 | } 9 | 10 | .scene p:last-child { 11 | margin-bottom: 0px; 12 | } 13 | -------------------------------------------------------------------------------- /src/components/views/toolbar/index.jsx: -------------------------------------------------------------------------------- 1 | import { h } from 'preact'; 2 | import style from './index.module.css'; 3 | import { useTranslator } from '@eo-locale/preact'; 4 | import { useAtramentState } from 'src/atrament/hooks'; 5 | import Markup from 'src/components/ui/markup'; 6 | import { TOOLBAR_STORE_KEY, TOOLBAR_DEFAULT } from 'src/constants'; 7 | 8 | const Toolbar = () => { 9 | const translator = useTranslator(); 10 | const atramentState = useAtramentState([TOOLBAR_STORE_KEY]); 11 | const toolbarContent = atramentState[TOOLBAR_STORE_KEY] !== TOOLBAR_DEFAULT 12 | ? atramentState[TOOLBAR_STORE_KEY] 13 | : translator.translate('default.title'); 14 | 15 | return ( 16 |
17 | 18 |
19 | ) 20 | }; 21 | 22 | export default Toolbar; 23 | -------------------------------------------------------------------------------- /src/components/views/toolbar/index.module.css: -------------------------------------------------------------------------------- 1 | .toolbar { 2 | min-height: 2.5em; 3 | overflow: hidden; 4 | padding: 0.25em; 5 | padding-left: 0.5em; 6 | padding-right: 3rem; 7 | flex-grow: 0; 8 | flex-shrink: 0; 9 | flex-basis: auto; 10 | border-bottom: 1px solid var(--fg-color); 11 | font-family: var(--font-game); 12 | display: flex; 13 | align-items: center; 14 | justify-content: left; 15 | gap: 0.25rem; 16 | flex-wrap: wrap; 17 | } 18 | 19 | -------------------------------------------------------------------------------- /src/constants.js: -------------------------------------------------------------------------------- 1 | /* global __APP_VERSION__, __INK_SCRIPT__, __EMBED_FONTS__ */ 2 | 3 | import cfg from '../atrament.config.json'; 4 | 5 | export const appVersion = __APP_VERSION__; 6 | 7 | export const embedFonts = __EMBED_FONTS__; 8 | 9 | // Ink file 10 | export const gamePath = import.meta.env.MODE === 'production' && cfg.game?.zip 11 | ? cfg.game?.zip // only for production build, not available for "development" and "singlefile" 12 | : cfg.game?.path; 13 | export const gameFile = __INK_SCRIPT__; 14 | 15 | // Application ID 16 | // - uses page URL to make sure it's unique 17 | 18 | export const applicationID = [ 19 | 'Atrament://', 20 | window.location.host, 21 | window.location.pathname, 22 | gamePath, 23 | '/', 24 | gameFile 25 | ].join(''); 26 | 27 | //// Settings //// 28 | 29 | // i18n 30 | export const appLanguage = cfg.language || 'en'; 31 | export const appLocale = cfg.locale || 'en-US'; 32 | 33 | // theme 34 | export const gameDefaultTheme = cfg.theme; 35 | 36 | // font 37 | export const gameDefaultFont = cfg.font; 38 | 39 | // Font size range and step (percentage) 40 | export const defaultFontSize = 100; 41 | export const stepFontSize = 10; 42 | export const minFontSize = defaultFontSize - ( stepFontSize * 3); 43 | export const maxFontSize = defaultFontSize + ( stepFontSize * 5); 44 | 45 | 46 | //// Internal constants //// 47 | 48 | // error 49 | export const ERROR_STORE_KEY = 'ERROR'; 50 | 51 | // overlay 52 | export const OVERLAY_STORE_KEY = 'OVERLAY'; 53 | 54 | // toolbar 55 | export const TOOLBAR_STORE_KEY = 'TOOLBAR'; 56 | export const TOOLBAR_DEFAULT = '__DEFAULT__'; 57 | 58 | // known global tags (for debugger) 59 | 60 | export const KNOWN_GLOBAL_TAGS = [ 61 | 'title', 62 | 'author', 63 | 'theme', 64 | 'font', 65 | 'observe', 66 | 'persist', 67 | 'sessions', 68 | 'autosave', 69 | 'saves', 70 | 'load_from_checkpoints', 71 | 'continue_maximally', 72 | 'single_scene', 73 | 'scenes_align', 74 | 'choices', 75 | 'hypertext', 76 | 'toolbar', 77 | 'about', 78 | 'cover', 79 | 'background', 80 | 'debug' 81 | ]; 82 | -------------------------------------------------------------------------------- /src/context.js: -------------------------------------------------------------------------------- 1 | import { createContext } from 'preact'; 2 | 3 | export const AtramentContext = createContext(null); 4 | export const ActiveContentContext = createContext(true); 5 | -------------------------------------------------------------------------------- /src/fonts.js: -------------------------------------------------------------------------------- 1 | // font styles 2 | import { gameDefaultFont, embedFonts } from 'src/constants'; 3 | 4 | const emojiFonts = '"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"'; 5 | 6 | const systemFonts = { 7 | System: `-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, ${emojiFonts}`, 8 | 'Sans Serif': `"Trebuchet MS", "Lucida Grande", Arial, Helvetica, ${emojiFonts}`, 9 | Serif: `"Palatino", "Cambria", "Lucida Bright", "Georgia", "Times New Roman", ${emojiFonts}`, 10 | Monospaced: `"Monaco", "Consolas", "Lucida Console", "Courier New", ${emojiFonts}` 11 | } 12 | 13 | let fonts = systemFonts; 14 | 15 | (async () => { 16 | if (import.meta.env.MODE !== 'singlefile' || embedFonts) { 17 | // import font modules 18 | const extFonts = {}; 19 | const modules = import.meta.glob('../resources/fonts/**/*.js'); 20 | await Promise.all( 21 | Object.values(modules).map( 22 | (mod) => mod().then((fontmodule) => { 23 | const fnt = fontmodule.default; 24 | if (fnt.name) { 25 | extFonts[fnt.name] = `${fnt.name}, ${fnt.fallback || 'serif'}, ${emojiFonts}`; 26 | } 27 | }) 28 | ) 29 | ); 30 | // add external fonts after the end of system fonts list 31 | Object.keys(extFonts).sort().forEach(fnt => (fonts[fnt] = extFonts[fnt])); 32 | } 33 | })(); 34 | 35 | export { 36 | fonts 37 | }; 38 | 39 | export function applyFont(font) { 40 | document.documentElement.style.setProperty('--font-game', fonts[font] || fonts[gameDefaultFont]); 41 | } 42 | -------------------------------------------------------------------------------- /src/i18n.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "language": "en", 4 | "messages": { 5 | "yes": "Yes", 6 | "no": "No", 7 | "cancel": "Cancel", 8 | "back": "Go back", 9 | "default.title": "Atrament UI", 10 | "default.author": "Test Application", 11 | "main.menu": "Main menu", 12 | "main.newgame": "New game", 13 | "main.loadgame": "Load game", 14 | "main.savegame": "Save game", 15 | "main.continue": "Continue", 16 | "main.emptyslot": "Empty slot", 17 | "main.continue-session": "Continue game {session}", 18 | "main.autosave": "Autosave: {date}", 19 | "main.checkpoint": "Checkpoint", 20 | "main.checkpoint-named": "Checkpoint: {name}", 21 | "main.new-save": "New save", 22 | "main.delete-session": "Delete this session?", 23 | "main.delete-save": "Delete this saved game?", 24 | "main.confirm-load": "Load this game?", 25 | "main.confirm-save": "Overwrite this save?", 26 | "main.settings": "Settings", 27 | "main.about": "About", 28 | "main.exit": "Exit", 29 | "game.end": "The end", 30 | "game.save-and-quit": "Save game and quit", 31 | "game.quit": "Quit game", 32 | "game.click-to-continue": "Click to continue", 33 | "settings.fullscreen": "Fullscreen", 34 | "settings.animations": "Animations", 35 | "settings.sound": "Sound", 36 | "settings.appearance": "Appearance", 37 | "themes.light": "light", 38 | "themes.sepia": "sepia", 39 | "themes.dark": "dark", 40 | "font.sampleText": "The quick brown fox jumps over the lazy dog. Jackdaws love my big sphinx of quartz.", 41 | "debug": "Debug", 42 | "debug.info": "Info", 43 | "debug.info.ink-file": "Ink file", 44 | "debug.info.story-seed": "Story seed", 45 | "debug.info.current-turn-index": "Current turn index", 46 | "debug.info.path": "Path", 47 | "debug.global-tags": "Global tags", 48 | "debug.unknown-tag": "Unknown tag", 49 | "debug.variables": "Variables", 50 | "debug.variables.filter-by-name": "Filter by name", 51 | "debug.variables.name": "Name", 52 | "debug.variables.value": "Value", 53 | "debug.variables.cancel": "Cancel", 54 | "debug.variables.save": "Save", 55 | "debug.visits": "Visits", 56 | "debug.go-to-path": "Go to path", 57 | "debug.functions": "Functions", 58 | "debug.functions.arguments": "Arguments", 59 | "debug.functions.arguments_placeholder": "JSON-formatted agruments, i.e. [\"arg1\", 5]", 60 | "debug.functions.run-function": "Run function", 61 | "debug.functions.returned-value": "Returned value", 62 | "debug.functions.output": "Output", 63 | "about.components": "Components and libraries", 64 | "about.iftf_support": "Made with the support of the {iftf}" 65 | } 66 | }, 67 | { 68 | "language": "ua", 69 | "messages": { 70 | "yes": "Так", 71 | "no": "Ні", 72 | "cancel": "Скасувати", 73 | "back": "Назад", 74 | "default.title": "Atrament UI", 75 | "default.author": "Тестовий застосунок", 76 | "main.menu": "Головне меню", 77 | "main.newgame": "Нова гра", 78 | "main.loadgame": "Завантажити гру", 79 | "main.savegame": "Зберегти гру", 80 | "main.continue": "Продовжити", 81 | "main.emptyslot": "Порожній слот", 82 | "main.continue-session": "Продовжити гру {session}", 83 | "main.autosave": "Автозбереження: {date}", 84 | "main.checkpoint": "Контрольна точка", 85 | "main.checkpoint-named": "Контрольна точка: {name}", 86 | "main.new-save": "Нове збереження", 87 | "main.delete-session": "Видалити сесію?", 88 | "main.delete-save": "Видалити збережену гру?", 89 | "main.confirm-load": "Завантажити цю гру?", 90 | "main.confirm-save": "Перезаписати це збереження?", 91 | "main.settings": "Налаштування", 92 | "main.about": "Про гру", 93 | "main.exit": "Вихід", 94 | "game.end": "Кінець", 95 | "game.save-and-quit": "Зберегти гру та вийти", 96 | "game.quit": "Вийти з гри", 97 | "game.click-to-continue": "Натисни, щоб продовжити", 98 | "settings.fullscreen": "На весь екран", 99 | "settings.animations": "Анімації", 100 | "settings.sound": "Звук", 101 | "settings.appearance": "Вигляд", 102 | "themes.light": "Світла", 103 | "themes.sepia": "Сепія", 104 | "themes.dark": "Темна", 105 | "font.sampleText": "Хвацький юшковар Філіп щодня на ґанку готує сім'ї вечерю з жаб.", 106 | "debug": "Налагодження", 107 | "debug.info": "Інформація", 108 | "debug.info.ink-file": "Ink-скрипт", 109 | "debug.info.story-seed": "RNG-сід історії", 110 | "debug.info.current-turn-index": "Індекс поточного ходу", 111 | "debug.info.path": "Поточний шлях", 112 | "debug.global-tags": "Глобальні теги", 113 | "debug.unknown-tag": "Невідомий тег", 114 | "debug.variables": "Змінні", 115 | "debug.variables.filter-by-name": "Фільтр за ім'ям", 116 | "debug.variables.name": "Ім'я", 117 | "debug.variables.value": "Значення", 118 | "debug.variables.cancel": "Скасувати", 119 | "debug.variables.save": "Зберегти", 120 | "debug.visits": "Візити", 121 | "debug.go-to-path": "Перейти до", 122 | "debug.functions": "Функції", 123 | "debug.functions.arguments": "Аргументи", 124 | "debug.functions.arguments_placeholder": "Аргументи у JSON-форматі, наприклад: [\"arg1\", 5]", 125 | "debug.functions.run-function": "Виконати функцію", 126 | "debug.functions.returned-value": "Повернене значення", 127 | "debug.functions.output": "Вивід", 128 | "about.components": "Компоненти та бібліотеки", 129 | "about.iftf_support": "Зроблено за підтримки фонду {iftf}" 130 | } 131 | } 132 | ] -------------------------------------------------------------------------------- /src/index.jsx: -------------------------------------------------------------------------------- 1 | import { render } from 'preact'; 2 | 3 | // CSS 4 | import 'normalize.css'; 5 | import 'src/app.css'; 6 | import '../resources/styles/custom.css'; 7 | 8 | // App 9 | import App from './components/app'; 10 | 11 | // Patches 12 | import 'src/utils/neutralino-out-of-bounds-fix'; 13 | 14 | render(, document.getElementById('app')); 15 | -------------------------------------------------------------------------------- /src/themes.js: -------------------------------------------------------------------------------- 1 | import { gameDefaultTheme } from 'src/constants'; 2 | 3 | // import theme modules 4 | const themes = {}; 5 | 6 | const allThemes = {}; 7 | const modules = import.meta.glob('../resources/themes/*.json', { eager: true }); 8 | Object.values(modules).map(({ default: theme }) => { 9 | if (theme) { 10 | allThemes[theme.name] = theme.theme; 11 | } 12 | }); 13 | // add internal themes, if they are present 14 | ['light', 'sepia', 'dark'].forEach((t) => { 15 | if (allThemes[t]) { 16 | themes[t] = allThemes[t]; 17 | delete allThemes[t]; 18 | } 19 | }); 20 | // add the rest of the themes 21 | Object.keys(allThemes).sort().forEach(t => (themes[t] = allThemes[t])); 22 | 23 | export { themes }; 24 | 25 | export function applyTheme(theme) { 26 | if (!theme || !themes[theme]) { 27 | return; 28 | } 29 | Object.entries(themes[theme] || themes[gameDefaultTheme]).forEach(([prop, value]) => { 30 | document.documentElement.style.setProperty(`--${prop}`, value); 31 | }); 32 | } 33 | 34 | -------------------------------------------------------------------------------- /src/utils/array-shuffle.js: -------------------------------------------------------------------------------- 1 | export default function arrayShuffle(array) { 2 | if (!Array.isArray(array)) { 3 | throw new TypeError(`Expected an array, got ${typeof array}`); 4 | } 5 | 6 | if (array.length === 0) { 7 | return []; 8 | } 9 | 10 | const shuffled = JSON.parse(JSON.stringify(array)); 11 | 12 | for (let index = shuffled.length - 1; index > 0; index--) { 13 | const newIndex = Math.floor(Math.random() * (index + 1)); 14 | [shuffled[index], shuffled[newIndex]] = [shuffled[newIndex], shuffled[index]]; 15 | } 16 | 17 | return shuffled; 18 | } 19 | -------------------------------------------------------------------------------- /src/utils/get-tag-attributes.js: -------------------------------------------------------------------------------- 1 | export default function getTagAttributes(tag) { 2 | const attrs = tag.match(/(\w+)=["']?((?:.?(?!["']?\s+(?:\S+)=|\s*\/?[>"']))+.)["']?/g); 3 | const attributes = {}; 4 | attrs && attrs.forEach((item) => { 5 | const [, name, value] = item.match(/(.+)=(.+)/); 6 | try { 7 | attributes[name] = JSON.parse(value); 8 | } catch(e) { 9 | attributes[name] = value; 10 | } 11 | }); 12 | return attributes; 13 | } 14 | -------------------------------------------------------------------------------- /src/utils/mute-when-inactive.js: -------------------------------------------------------------------------------- 1 | /* eslint-env browser */ 2 | export default function muteWhenInactive(atrament) { 3 | document.addEventListener('visibilitychange', () => { 4 | if (document.visibilityState === 'visible') { 5 | atrament.interfaces.sound.mute(atrament.settings.get('mute')); 6 | } else { 7 | atrament.interfaces.sound.mute(true); 8 | } 9 | }); 10 | } 11 | -------------------------------------------------------------------------------- /src/utils/neutralino-out-of-bounds-fix.js: -------------------------------------------------------------------------------- 1 | async function neutralinoOutOfBoundsFix() { 2 | const maxBounds = 10000; 3 | const xOutOfBounds = window.screenX < -maxBounds || window.screenX > window.screen.availWidth + maxBounds; 4 | const yOutOfBounds = window.screenY < -maxBounds || window.screenY > window.screen.availHeight + maxBounds; 5 | if (xOutOfBounds || yOutOfBounds) { 6 | console.warn("fixing unmaximize bug", window.screenX, window.screenY); 7 | await window.Neutralino.window.center(); 8 | } 9 | } 10 | 11 | if (window.Neutralino) { 12 | addEventListener("resize", neutralinoOutOfBoundsFix); 13 | addEventListener('DOMContentLoaded', neutralinoOutOfBoundsFix); 14 | } 15 | -------------------------------------------------------------------------------- /src/utils/page-background.js: -------------------------------------------------------------------------------- 1 | export const setPageBackground = (background, getAssetPath) => { 2 | if (background) { 3 | document.body.style.backgroundImage = `url(${getAssetPath(background)})` 4 | document.body.style.backgroundSize = 'cover'; 5 | document.body.style.backgroundPosition = 'center'; 6 | } else { 7 | document.body.style.backgroundImage = 'none'; 8 | } 9 | }; 10 | -------------------------------------------------------------------------------- /src/utils/preload-images.js: -------------------------------------------------------------------------------- 1 | export default function preloadImages(getAssetPath, imageList) { 2 | const imagePreloads = []; 3 | for (let img of imageList) { 4 | imagePreloads.push(new Promise((resolve) => { 5 | const imgToPreload = new Image(); 6 | imgToPreload.onload = resolve; 7 | imgToPreload.onerror = resolve; 8 | imgToPreload.src = getAssetPath(img); 9 | })); 10 | } 11 | return Promise.allSettled(imagePreloads); 12 | } 13 | -------------------------------------------------------------------------------- /tools/ink-compile.cjs: -------------------------------------------------------------------------------- 1 | /* 2 | Compiles Ink to JSON, using Inklecate compiler for host OS. 3 | To enforce using JS compiler from InkJS, add this line to attrament.config.json: 4 | "inkjscompiler": true 5 | */ 6 | 7 | const { spawn, spawnSync } = require('node:child_process'); 8 | const os = require('node:os'); 9 | const fs = require('node:fs'); 10 | 11 | const cfg = require('../atrament.config.json'); 12 | 13 | if (!cfg.game.source) { 14 | process.exit(0); 15 | } 16 | 17 | const format = process.argv[2] || 'json'; 18 | 19 | const inputFile = `root/${cfg.game.path}/${cfg.game.source}`; 20 | const outputFile = `root/${cfg.game.path}/${cfg.game.source}.${format}`; 21 | 22 | function removeOldCompiledScript(name) { 23 | if (fs.existsSync(name)) { 24 | fs.unlinkSync(name); 25 | } 26 | } 27 | 28 | function checkInstallInklecate(compiler) { 29 | if (!fs.existsSync(compiler)) { 30 | // let's try to install inklecate 31 | console.log("Inklecate compiler is not found, installing from GitHub..."); 32 | const res = spawnSync('node', ['tools/install-inklecate.cjs']); 33 | [ res.stdout.toString(), res.stderr.toString() ].forEach( 34 | (item) => item && console.log(item) 35 | ); 36 | } 37 | if (!fs.existsSync(compiler)) { 38 | console.error("Failed to install Inklecate"); 39 | process.exit(1); 40 | } 41 | } 42 | 43 | const runInklecate = (cmd, ...args) => { 44 | console.log('>>>', cmd, args.join(' ')); 45 | 46 | const inkCompilerProcess = spawn(cmd, args); 47 | 48 | inkCompilerProcess.stdout.on('data', (data) => { 49 | console.log(data.toString()); 50 | }); 51 | 52 | inkCompilerProcess.stderr.on('data', (data) => { 53 | console.error(data.toString()); 54 | }); 55 | 56 | inkCompilerProcess.on('close', () => { 57 | if (format === 'js') { 58 | // convert json output to JS 59 | const content = fs.readFileSync(outputFile, { encoding: 'utf8', flag: 'r' }); 60 | const output = `var storyContent = ${content};`; 61 | fs.writeFileSync(outputFile, output); 62 | } 63 | process.exit(inkCompilerProcess.exitCode) 64 | }); 65 | } 66 | 67 | const inklecateRun = { 68 | js: ['node', 'node_modules/inkjs/dist/inkjs-compiler.js', inputFile, '-o', outputFile], 69 | win32: ['tools/inklecate/inklecate.exe', '-o', outputFile, inputFile], 70 | linux: ['tools/inklecate/inklecate', '-o', outputFile, inputFile], 71 | darwin: ['tools/inklecate/inklecate', '-o', outputFile, inputFile] 72 | } 73 | 74 | let env = 'js'; 75 | if (!cfg.inkjscompiler) { 76 | env = os.platform(); 77 | if (!['win32', 'linux', 'darwin'].includes(env)) { 78 | console.log(`Unsupported OS (${env}), falling back to JS compiler.`); 79 | env = 'js'; 80 | } else { 81 | // check if compiler is installed 82 | checkInstallInklecate(inklecateRun[env][0]); 83 | // ensure Ink compiler is executable 84 | fs.chmodSync(inklecateRun[env][0], '755'); 85 | } 86 | } 87 | 88 | // clean old compiles 89 | removeOldCompiledScript(`${inputFile}.js`); 90 | removeOldCompiledScript(`${inputFile}.json`); 91 | // run compiler 92 | runInklecate(...inklecateRun[env]); 93 | -------------------------------------------------------------------------------- /tools/install-inklecate.cjs: -------------------------------------------------------------------------------- 1 | const fs = require('node:fs'); 2 | const os = require('node:os'); 3 | 4 | const user = 'inkle'; 5 | const repo = 'ink'; 6 | const outputdir = `./tools/inklecate`; 7 | const leaveZipped = false; 8 | const disableLogging = false; 9 | 10 | const platforms = { 11 | win32: 'windows', 12 | linux: 'linux', 13 | darwin: 'mac' 14 | } 15 | 16 | async function downloadInklecate(platform) { 17 | const { downloadRelease } = await import('@terascope/fetch-github-release'); 18 | const filterRelease = (release) => (release.prerelease === false); 19 | const filterAsset = (asset) => asset.name.includes(platform); 20 | await fs.promises.mkdir(outputdir, { recursive: true }); 21 | await downloadRelease(user, repo, outputdir, filterRelease, filterAsset, leaveZipped, disableLogging); 22 | if (platform !== 'windows') { 23 | await fs.promises.chmod(`${outputdir}/inklecate`, '755'); 24 | } 25 | } 26 | 27 | async function main() { 28 | const env = os.platform(); 29 | const platform = platforms[env]; 30 | if (platform) { 31 | try { 32 | await downloadInklecate(platform); 33 | } catch (e) { 34 | console.error(e); 35 | } 36 | } else { 37 | console.warn(`Inklecate is not available for platform: ${env}. Atrament UI will use JS compiler instead.`); 38 | } 39 | } 40 | 41 | main(); 42 | -------------------------------------------------------------------------------- /tools/neutralino-postbuild.mjs: -------------------------------------------------------------------------------- 1 | import { zip } from 'zip-a-folder'; 2 | import { readdir } from 'node:fs/promises'; 3 | 4 | async function main() { 5 | const files = await readdir('.'); 6 | const appName = files[0]; 7 | await zip('**/*-linux_*, **/*.neu', `${appName}-linux.zip`); 8 | await zip('**/*-mac_*, **/*.neu', `${appName}-mac.zip`); 9 | await zip('**/*-win_*, **/*.neu', `${appName}-windows.zip`); 10 | } 11 | 12 | main(); 13 | console.log('>>> Standalone build complete.'); 14 | -------------------------------------------------------------------------------- /tools/neutralino-prepare.cjs: -------------------------------------------------------------------------------- 1 | const { spawn } = require('node:child_process'); 2 | const fs = require('node:fs'); 3 | const copy = require('recursive-copy'); 4 | const cfg = require('../atrament.config.json'); 5 | 6 | const TOOLS_DIR = 'tools/neutralino'; 7 | const BUILD_DIR = 'build/.tmp_neutralino'; 8 | 9 | const neutralinoConfig = { 10 | $schema: "https://raw.githubusercontent.com/neutralinojs/neutralinojs/main/schemas/neutralino.config.schema.json", 11 | applicationId: cfg.short_name || "atrament.neutralino.ui", 12 | version: cfg.version || "1.0.0", 13 | defaultMode: "window", 14 | port: 0, 15 | documentRoot: "/resources/", 16 | url: "/", 17 | enableServer: true, 18 | enableNativeAPI: true, 19 | tokenSecurity: "one-time", 20 | logging: { 21 | enabled: false 22 | }, 23 | nativeAllowList: [ 24 | "app.*", 25 | "storage.*", 26 | "window.*" 27 | ], 28 | modes: { 29 | window: { 30 | title: cfg.name || "Atrament UI", 31 | width: 600, 32 | height: 800, 33 | minWidth: 400, 34 | minHeight: 600, 35 | center: true, 36 | fullScreen: false, 37 | alwaysOnTop: false, 38 | icon: "/resources/pwa-512x512.png", 39 | enableInspector: false, 40 | borderless: false, 41 | maximize: true, 42 | hidden: false, 43 | resizable: true, 44 | exitProcessOnClose: true 45 | }, 46 | browser: { 47 | nativeBlockList: [ 48 | "filesystem.*" 49 | ] 50 | } 51 | }, 52 | cli: { 53 | binaryName: cfg.name ? cfg.name.replace(/ /g, '_') : "Atrament_UI", 54 | resourcesPath: "/resources/", 55 | extensionsPath: "/extensions/", 56 | clientLibrary: "/resources/neutralino.js" 57 | } 58 | }; 59 | 60 | 61 | 62 | function copyNeutralinoFiles() { 63 | console.log('>>> Copy Neutralino binaries to build dir'); 64 | copy(TOOLS_DIR, BUILD_DIR, { overwrite: true }) 65 | .then((results) => { 66 | console.info(`Copied ${results.length} files`); 67 | }).catch((error) => { 68 | console.error(`Copy failed: ${error}`); 69 | }); 70 | } 71 | 72 | 73 | // 1. Create config file 74 | 75 | if (!fs.existsSync(TOOLS_DIR)) { 76 | console.log(`>>> Create tools directory ${TOOLS_DIR}`); 77 | fs.mkdirSync(TOOLS_DIR); 78 | } 79 | console.log('>>> Generate neutralino.config.json'); 80 | fs.writeFileSync( 81 | `${TOOLS_DIR}/neutralino.config.json`, 82 | JSON.stringify(neutralinoConfig, undefined, " ") 83 | ); 84 | 85 | // 2. Download Neutralino binaries, if needed 86 | 87 | if (!fs.existsSync(`${TOOLS_DIR}/bin`)) { 88 | console.log('>>> Download Neutralino binaries'); 89 | const neu = spawn('npx', ['neu', 'update'], { cwd: TOOLS_DIR, shell: true }); 90 | 91 | neu.stdout.on('data', (data) => { 92 | console.log(data.toString()); 93 | }); 94 | 95 | neu.stderr.on('data', (data) => { 96 | console.error(`ERROR: ${data.toString()}`); 97 | }); 98 | 99 | neu.on('close', (code) => { 100 | if (!fs.existsSync(`${TOOLS_DIR}/bin`)) { 101 | console.error("Failed to install Neutralino"); 102 | process.exit(1); 103 | } else { 104 | copyNeutralinoFiles(); 105 | } 106 | }); 107 | } else { 108 | copyNeutralinoFiles(); 109 | } 110 | -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | import preact from '@preact/preset-vite'; 3 | import { VitePWA } from 'vite-plugin-pwa'; 4 | import { createHtmlPlugin } from 'vite-plugin-html'; 5 | import { viteSingleFile } from 'vite-plugin-singlefile'; 6 | import zipPack from "vite-plugin-zip-pack"; 7 | import CleanBuild from 'vite-plugin-clean-build'; 8 | 9 | import { compileInk, watchInkFiles } from './vite/ink-compiler-plugin'; 10 | import { removeInkFilesFromBuild } from './vite/remove-ink-files-plugin'; 11 | import getPWAConfig from './vite/pwa-config'; 12 | 13 | import atramentCfg from './atrament.config.json'; 14 | 15 | export default defineConfig(({ mode }) => { 16 | const inkCompileFormat = atramentCfg.game.format || (mode === 'singlefile' ? 'js' : 'json'); 17 | 18 | const neutralinoTemplate = mode === 'standalone' 19 | ? '' 20 | : ''; 21 | 22 | const plugins = [ 23 | preact(), 24 | createHtmlPlugin({ 25 | inject: { 26 | data: { 27 | title: atramentCfg.name, 28 | description: atramentCfg.description, 29 | neutralino: neutralinoTemplate 30 | }, 31 | }, 32 | }), 33 | watchInkFiles(inkCompileFormat), 34 | compileInk(inkCompileFormat), 35 | removeInkFilesFromBuild(), 36 | ] 37 | 38 | let buildDir = 'build/web'; 39 | 40 | if (mode === 'singlefile') { 41 | plugins.push(viteSingleFile()); 42 | buildDir = 'build/singlefile'; 43 | } else if (mode === 'standalone') { 44 | plugins.push(VitePWA(getPWAConfig(atramentCfg))); 45 | buildDir = 'build/.tmp_neutralino/resources'; 46 | } else if (mode === 'production') { 47 | plugins.push(VitePWA(getPWAConfig(atramentCfg))); 48 | if (atramentCfg.game.zip) { 49 | const gameDir = `${buildDir}/${atramentCfg.game.path}`; 50 | plugins.push(zipPack({ 51 | inDir: gameDir, 52 | outDir: 'build', 53 | outFileName: atramentCfg.game.zip 54 | })); 55 | // delete game folder after zipping 56 | plugins.push(CleanBuild({ 57 | outputDir: buildDir, 58 | patterns: [ 59 | atramentCfg.game.path, 60 | ] 61 | })); 62 | } 63 | } 64 | 65 | return { 66 | plugins, 67 | define: { 68 | __INK_SCRIPT__: JSON.stringify(`${atramentCfg.game.source}.${inkCompileFormat}`), 69 | __APP_VERSION__: JSON.stringify(process.env.npm_package_version), 70 | __EMBED_FONTS__: process.argv.includes('--embed-fonts') 71 | }, 72 | resolve: { 73 | alias: [ 74 | { find: 'src', replacement: "/src" }, 75 | { find: 'inkjs', replacement: '/node_modules/inkjs/dist/ink.mjs' } 76 | ], 77 | }, 78 | server: { 79 | port: 8900 80 | }, 81 | build: { 82 | outDir: buildDir 83 | }, 84 | publicDir: 'root', 85 | base: '' 86 | }; 87 | }); 88 | -------------------------------------------------------------------------------- /vite/ink-compiler-plugin.js: -------------------------------------------------------------------------------- 1 | import { spawnSync } from 'child_process'; 2 | 3 | function renderError(server, error) { 4 | if (error) { 5 | server.ws.send({ 6 | type: 'error', 7 | err: { 8 | message: error 9 | } 10 | }); 11 | } 12 | } 13 | 14 | 15 | function runCompiler(format = 'json') { 16 | const res = spawnSync('node', ['tools/ink-compile.cjs', format]); 17 | [ res.stdout.toString(), res.stderr.toString() ].forEach( 18 | (item) => item && console.log(item) 19 | ); 20 | return res; 21 | } 22 | 23 | export function compileInk(format) { 24 | let inkCompilerError; 25 | return { 26 | name: 'compile-ink', 27 | configureServer(server) { 28 | renderError(server, inkCompilerError); 29 | }, 30 | configResolved({ mode }) { 31 | const res = runCompiler(format); 32 | if (res.status !== 0) { 33 | inkCompilerError = res.stdout.toString(); 34 | if (mode !== 'development') { 35 | throw new Error(inkCompilerError); 36 | } 37 | } 38 | return res; 39 | }, 40 | } 41 | } 42 | 43 | export function watchInkFiles(format) { 44 | return { 45 | name: 'watch-ink-files-hmr', 46 | enforce: 'post', 47 | handleHotUpdate({ file, server }) { 48 | if (file.endsWith('.ink')) { 49 | const res = runCompiler(format); 50 | if (res.status === 0) { 51 | server.hot.send({ 52 | type: 'full-reload', 53 | path: '*' 54 | }); 55 | } else { 56 | renderError(server, res.stdout.toString()) 57 | } 58 | } 59 | }, 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /vite/pwa-assets.config.js: -------------------------------------------------------------------------------- 1 | import { 2 | defineConfig, 3 | minimal2023Preset as preset 4 | } from '@vite-pwa/assets-generator/config'; 5 | 6 | export default defineConfig({ 7 | headLinkOptions: { 8 | preset: '2023' 9 | }, 10 | preset, 11 | images: ['root/logo.png'] 12 | }); 13 | -------------------------------------------------------------------------------- /vite/pwa-config.js: -------------------------------------------------------------------------------- 1 | export default function getPWAConfig(atramentCfg) { 2 | let pwaConfig = { 3 | registerType: 'autoUpdate', 4 | includeAssets: ['**/!(*.ink)'], 5 | workbox: { 6 | globPatterns: ['**/*.{js,css,html,woff2}'], 7 | }, 8 | manifest: { 9 | name: atramentCfg.name, 10 | short_name: atramentCfg.short_name, 11 | description: atramentCfg.description, 12 | start_url: "./", 13 | display: "standalone", 14 | orientation: "portrait", 15 | background_color: "#fff", 16 | theme_color: "#673ab8", 17 | icons: [ 18 | { 19 | src: "./pwa-192x192.png", 20 | type: "image/png", 21 | sizes: "192x192" 22 | }, 23 | { 24 | src: "./pwa-512x512.png", 25 | type: "image/png", 26 | sizes: "512x512" 27 | } 28 | ] 29 | }, 30 | pwaAssets: { 31 | config: 'vite/pwa-assets.config.js' 32 | } 33 | }; 34 | 35 | if (atramentCfg.game.zip) { 36 | // game folder will be removed, so don't include these files into service worker 37 | pwaConfig.includeAssets = [ `!./${atramentCfg.game.path}/**` ]; 38 | } 39 | 40 | return pwaConfig; 41 | } 42 | -------------------------------------------------------------------------------- /vite/remove-ink-files-plugin.js: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import fs from 'fs'; 3 | 4 | function findFiles(startPath, filter, callback) { 5 | if (!fs.existsSync(startPath)) { 6 | console.log("no dir ", startPath); 7 | return; 8 | } 9 | const files = fs.readdirSync(startPath); 10 | files.forEach((file) => { 11 | const filename = path.join(startPath, file); 12 | const stat = fs.lstatSync(filename); 13 | if (stat.isDirectory()) { 14 | findFiles(filename, filter, callback); //recurse 15 | } else if (filter.test(filename)) callback(filename); 16 | }); 17 | } 18 | 19 | export function removeInkFilesFromBuild() { 20 | return { 21 | name: 'remove-files-from-build', 22 | enforce: 'post', 23 | apply: 'build', 24 | writeBundle(options) { 25 | findFiles(options.dir, /\.ink$/, (item) => { 26 | console.log(`Removing Ink file from bundle: ${item}`); 27 | fs.unlinkSync(item); 28 | }); 29 | }, 30 | }; 31 | } --------------------------------------------------------------------------------