Compare commits

...

120 commits

Author SHA1 Message Date
3ef278fa5f TextBuffer: add scroll targets 2024-01-27 03:28:05 +01:00
6cba8066c1 package.json: update eslint 2023-07-03 10:31:37 +03:00
c3074d66f8 package-lock.json: npm audit fix 2023-06-29 22:20:47 +03:00
483c8ec806 package.json: update packages 2023-06-29 22:20:47 +03:00
312a959202
Update README.md 2023-06-29 22:12:38 +03:00
2c2c6cded4
Update README.md 2023-06-29 21:41:38 +03:00
3943ca9ea3 Update CHANGELOG.md 2023-06-29 21:38:31 +03:00
61cbb65067 0.2.0 2023-06-29 21:38:31 +03:00
898d2d8aa6 Update README.md 2023-06-29 21:38:31 +03:00
db61461cb5 Update README.md 2023-06-29 21:38:31 +03:00
7280dffd37 Update README.md 2023-06-29 21:38:31 +03:00
3c6315e033 Update README.md 2023-06-29 21:38:31 +03:00
9f3e1d8c9a README.md: draft 2023-06-29 21:38:31 +03:00
9a26ed1822 Player: eslint 2023-06-29 21:38:31 +03:00
6585eee27f Engines: add ADRIFT 4 support 2023-06-29 21:38:31 +03:00
229500b861 TargetURLSelector: encode URL 2023-06-29 21:38:31 +03:00
ade6f5d2c3 Player: replace player.css with scss modules 2023-06-29 21:38:31 +03:00
ae9456d6dd Views: replace css files with scss modules 2023-06-29 21:38:31 +03:00
40a6e7eb3a controls.scss: extract scrollbar styles 2023-06-29 21:38:31 +03:00
47d61f9b35 base.scss: convert css to scss 2023-06-29 21:38:31 +03:00
8f9fddb73c index.html: remove fake markup 2023-06-29 21:38:31 +03:00
3062140352 Status: extract styles 2023-06-29 21:38:31 +03:00
ae9d57fe44 NotFoundPage: use Status component 2023-06-29 21:38:31 +03:00
36efea1d09 styles: extract components.scss 2023-06-29 21:38:31 +03:00
ceafed4a5c index.js: extract App.jsx 2023-06-29 21:38:31 +03:00
0d0e42a26a components: remove redundant h import 2023-06-29 21:38:31 +03:00
ee752b91e2 base.css: replace values with vars 2023-06-29 21:38:31 +03:00
500b156968 eslint: add braces rule 2023-06-29 21:38:31 +03:00
e0a90005e6 routing: ignore repeats 2023-06-29 21:38:31 +03:00
54512df52c base.css: white flash prevention 2023-06-29 21:38:31 +03:00
832fd2faa7 Controls: tune Select style 2023-06-29 21:38:31 +03:00
cce894176f InputBox: focus on fullscreen enter 2023-06-29 21:38:31 +03:00
759d221c40 MenuOverlay: increase menu width 2023-06-29 21:38:31 +03:00
d5e89b9bdc MenuOverlay: accurate handling of the dialog ref 2023-06-29 21:38:31 +03:00
12e894b41c TextBuffer: input text scroll margin 2023-06-29 21:38:31 +03:00
69460a7f55 MenuButton: markup for small screens 2023-06-29 21:38:31 +03:00
982da41acb MenuButton: pixel perfect svg icon 2023-06-29 21:38:31 +03:00
d01dd59598 MenuButton: add svg icon 2023-06-29 21:38:31 +03:00
3931685b08 MenuButton: hide button borders 2023-06-29 21:38:31 +03:00
22cc737f8e InputBox: ignore keyboard shortcuts 2023-06-29 21:38:31 +03:00
674c6c7c51 InputBox: extract MenuButton 2023-06-29 21:38:31 +03:00
daa26965cd Rearrange components 2023-06-29 21:38:31 +03:00
39e8b8a526 InputBox: tune input attributes 2023-06-29 21:38:31 +03:00
92e874e263 ThemesView: increase gaps 2023-06-29 21:38:31 +03:00
921b24195d MenuOverlay: focus trap 2023-06-29 21:38:31 +03:00
a5c4386a80 package.json: update packages 2023-06-29 21:38:31 +03:00
17ffeab680 MenuOverlay: rearrange menu items 2023-06-29 21:38:31 +03:00
d5b176087e InputControls: menu button scaffold 2023-06-29 21:38:31 +03:00
4173349481 Player: pass functions to the InbutBox 2023-06-29 21:38:31 +03:00
2b344f7cd9 PlayerView: add MenuOverlay 2023-06-29 21:38:31 +03:00
f614deca5a MenuOverlay: add dialog 2023-06-29 21:38:31 +03:00
f4e94a63b1 Top2019: fixup broken URLs 2023-06-29 21:38:31 +03:00
ae4bdf5983 GridBuffer: deduplicate empty lines 2023-06-29 21:38:31 +03:00
a3fdb236d2 GridBuffer: recursion no more 2023-06-29 21:38:31 +03:00
2fdc39c1f0 Player: explicitly override Engine arguments 2023-06-29 21:38:31 +03:00
8a79c5dd3d package.json: update packages 2023-06-29 21:38:31 +03:00
f84da5ba79 GamesView: rearrange the game list 2023-06-29 21:38:31 +03:00
5863134c3e ThemesView: ESlint 2023-06-29 21:38:31 +03:00
12d34f3a40 HomeView: fixup input outlines 2023-06-29 21:38:31 +03:00
2ae3d64039 InputBox: disable on Game Over 2023-06-29 21:38:31 +03:00
6b03a1ea21 GamesView: format titles 2023-06-29 21:38:31 +03:00
b22a2802b2 HomeView: format titles 2023-06-29 21:38:31 +03:00
fd49342e12 Themes: add themes page 2023-06-29 21:38:31 +03:00
f85fadef2d package.json: update packages 2023-06-29 21:38:31 +03:00
78ad3d5657 playerHandlers: replace lz-string with base32768 2023-06-29 21:38:31 +03:00
1afbb95e05 Themes: add Monkeytype themes 2023-06-29 21:38:31 +03:00
91456a8bc9 Themes: add input colors and accents 2023-06-29 21:38:31 +03:00
7a58f92434 Player: add message on the vm termination 2023-06-29 21:38:31 +03:00
e7c1384436 UrlPlayer: process HTTP responses to invalid requests 2023-06-29 21:38:31 +03:00
4caf912ff1 package.json: update browserslist 2023-06-29 21:38:31 +03:00
9de3d951e5 Router: scroll to top on route change 2023-06-29 21:38:31 +03:00
e1267730bb PlayerView: respect the height of the virtual keyboard 2023-06-29 21:38:31 +03:00
8b6a805c36 PlayerView: show separators on small screen 2023-06-29 21:38:31 +03:00
fdd49f8621 PlayerView: focus as a mode 2023-06-29 21:38:31 +03:00
6eb20f8db3 PlayerView: break a word once it is too long to fit on a line 2023-06-29 21:38:31 +03:00
393d15a64f TextBuffer: smooth scrolling 2023-06-29 21:38:31 +03:00
b08d9725d4 Update the player to work with new versions of the Emglken and the cheap-glkote 2023-05-22 01:41:21 +03:00
c817d72827 Upgrade emglken and cheap-glkote 2023-05-22 01:41:21 +03:00
22c90ef633 Upgrade buffer 2023-05-22 00:21:19 +03:00
dfa4057ebd Upgrade preact 2023-05-22 00:21:19 +03:00
a8d48ce6b8 Upgrade from Parcel 1 to Parcel 2 2023-05-22 00:21:19 +03:00
7a96d99055 Linting 2023-05-21 16:49:41 +03:00
3288234f36 Update .eslintrc 2023-05-21 16:49:41 +03:00
b6687463e8 Update eslint 2023-05-21 16:49:41 +03:00
10126a988b Remove eslint 2023-05-21 16:49:41 +03:00
89593dd0bd npm update manual 2023-05-21 16:49:41 +03:00
99631a7cb1 npm update 2023-05-21 16:49:41 +03:00
0c7ca4abdb browserlist: update db 2023-05-21 16:49:41 +03:00
4746f9dcb5 Replace classes with classNames 2023-05-21 16:49:41 +03:00
dc22a4782f UrlPlayer: start the chain with the url 2023-05-21 16:49:41 +03:00
9fc2265e41 TextMessage: add classnames to all message types 2023-05-21 16:49:41 +03:00
d4b0abda46 Update package-lock.json 2023-05-21 16:49:41 +03:00
e5c96709d8 UrlPlayer: clearing the hash in the URL for Safari 2021-07-27 17:40:44 +05:00
ef8aca3476 styles: add special styles for Safari 2021-07-27 17:38:30 +05:00
42a6f25078 package-lock: update 2021-07-27 17:34:42 +05:00
c1058a8034 README.md: fix the broken link 2021-07-24 20:10:33 +05:00
fb182f0364 package.json: update 2021-07-24 19:56:54 +05:00
8699868c42 Rename the project to ElseIFPlayer 2021-07-24 19:54:46 +05:00
fa2635525c package.json: remove obsolete eslint plugins 2021-07-24 19:54:46 +05:00
71162ec2c6 InputBox: handle mobile keyboard events 2021-07-23 14:38:43 +05:00
1fe588af6e base.css: fix for Jumping Scrollbar Issue 2021-07-22 15:23:13 +05:00
1e0b16361f eslint fix 2021-07-22 15:04:19 +05:00
1e1614706a Update linters 2021-07-22 14:48:12 +05:00
cb73eb430d npm update 2021-07-22 14:01:10 +05:00
cc1df287be eslint fix 2021-07-22 14:00:58 +05:00
35e8f2ce6f Update README.md 2021-07-21 15:34:10 +05:00
2bd264659c GridBuffer: hide when the buffer contains only empty lines 2021-07-21 15:34:10 +05:00
0c5a763414 Player: extract handlers 2021-07-21 15:34:10 +05:00
18da26dc20 Player: add the singleWindow option 2021-07-21 15:34:10 +05:00
7240be1120 TextBuffer: add keyboard scrolling support 2021-07-21 15:34:10 +05:00
d95ee172bb TextBuffer: treat short buffers as a status 2021-07-21 15:34:10 +05:00
9db390c76c GridBuffer: hide when the buffer is empty 2021-07-21 15:34:10 +05:00
26cebc2596 goatCounter: suppress some errors when script is blocked by some adblocker 2021-07-21 15:34:10 +05:00
fae4b09483 Player: group all output windows 2021-07-21 15:34:10 +05:00
30d8cfec1a GridBuffer: skip empty inbox 2021-07-21 15:34:10 +05:00
752cb60b56 Add GridBuffer component 2021-07-21 15:34:10 +05:00
4747a3396c Add GridBuffer placeholder 2021-07-21 15:34:10 +05:00
393b1f1dd6 Update cheap-glkote to version 0.4.0 2021-07-21 15:34:10 +05:00
f4939aaf55 npm audit fix 2021-07-21 15:34:10 +05:00
5d92e1e3dd Update package-lock.json 2021-07-21 15:34:10 +05:00
59 changed files with 8766 additions and 8650 deletions

View file

@ -1,26 +1,46 @@
module.exports = {
env: {
browser: true,
es2021: true
'env': {
'browser': true,
'es2021': true
},
extends: [
'standard',
'standard-preact'
'extends': [
'eslint:recommended',
'preact',
],
overrides: [
'overrides': [
{
files: ['*.jsx', '*.js']
}
files: ['*.js', '*.jsx'],
},
],
parserOptions: {
ecmaVersion: 12,
sourceType: 'module'
'parserOptions': {
'ecmaVersion': 'latest',
'sourceType': 'module'
},
rules: {
},
settings: {
react: {
version: 'latest'
}
'rules': {
'jest/no-deprecated-functions': 0,
'arrow-parens': ['error', 'always'],
'comma-dangle': ['error', 'always-multiline'],
'indent': [
'error',
2
],
'linebreak-style': [
'error',
'unix'
],
'object-curly-spacing': [
'error',
'always'
],
'quotes': [
'error',
'single'
],
'semi': [
'error',
'never'
],
}
}

2
.gitignore vendored
View file

@ -1,4 +1,4 @@
node_modules/
dist/
.cache/
.parcel-cache/

4
.parcelrc Normal file
View file

@ -0,0 +1,4 @@
{
"extends": ["@parcel/config-default"],
"reporters": ["...", "parcel-reporter-static-files-copy"]
}

28
CHANGELOG.md Normal file
View file

@ -0,0 +1,28 @@
# 0.2.0
## Breaking Changes
- Changed direct links format
- Changed savefiles format
## Changes
- Added over 150 new interface themes
- Added themes preview page
- Implemented smooth scrolling when a new message appears
- Added an in-game menu
- Improved mobile user experience
- Upgraded Emglken to version 0.5.2
- Upgraded cheap-glkote to version 0.5.1
- Added support for ADRIFT 4
- Fixed broken links
- Enhanced error handling
- Refactored styles for improved code structure
# 0.1.0
- Added support for multiple output buffers
# 0.0.0
- Initial release

View file

@ -1,18 +1,65 @@
# ifplayer
# ElseIFPlayer
Interactive Fiction player for the web.
Powered by [cheap-glkote](https://github.com/He4eT/cheap-glkote) and [Emglken](https://github.com/curiousdannii/emglken).
ElseIFPlayer is an interactive fiction player for the web.
It's powered by [cheap-glkote](https://github.com/He4eT/cheap-glkote) and [Emglken](https://github.com/curiousdannii/emglken).
To see a live demo, check out [https://he4et.github.io/ifplayer/](https://he4et.github.io/ifplayer/).
Player available here: [https://he4et.github.io/elseifplayer/](https://he4et.github.io/elseifplayer/).
## Getting Started
- Ensure that you have Node.js and NPM installed on your system.
- Install the required packages by running the command `npm install` in your project directory.
- Launch the local development server using `npm run dev`.
## Build
To create a production build, use the following command:
```
npm run build <public-url>
```
- If you intend to host the player on `https://your.domain/`, use:
```
npm run build /
```
- For hosting it in a specific directory like `https://your.domain/some-directory/`, use:
```
npm run build /some-directory
```
The finalized production bundle will be generated and stored in the `/docs` directory.
## Direct links
You can provide the direct link to your game:
You can provide a direct link to a specific game using the following URL format:
```
/#/<mode>/<encodedURL>/[theme]/
```
`/#/play/encodedURL/[theme]/`
- `encodedURL` - storyfile location encoded with `encodeURIComponent`;
- `theme` - [UI theme](https://github.com/He4eT/ifplayer/blob/master/src/themes/themes.js), optional;
- `mode` specifies the player interface mode:
- `play`: the default multi-window mode
- `focus`: the single-window mode without additional windows, such as the status bar
- `encodedURL` represents the location of the storyfile encoded with `encodeURIComponent`.
- `theme` is optional and allows you to choose a specific UI theme.
### Examples
- [Play "Lost Pig" with default or last used theme](https://he4et.github.io/ifplayer/#/play/https%3A%2F%2Fmirror.ifarchive.org%2Fif-archive%2Fgames%2Fzcode%2FLostPig.z8/);
- [Play "Lost Pig" with Nord theme](https://he4et.github.io/ifplayer/#/play/https%3A%2F%2Fmirror.ifarchive.org%2Fif-archive%2Fgames%2Fzcode%2FLostPig.z8/nord/);
### CORS
If the player and your storyfile are located on different domains,
you need to use appropriate [CORS](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS) settings.
If you cannot modify the server settings, you can use the [Parchment Proxy](https://iplayif.com/proxy/) as an alternative.
### Direct Link Examples
- [Play "Lost Pig" with default or last used theme](https://he4et.github.io/elseifplayer/#/play/https%3A%2F%2Fmirror.ifarchive.org%2Fif-archive%2Fgames%2Fzcode%2FLostPig.z8/)
- [Play "Lost Pig" without statusbar with default or last used theme](https://he4et.github.io/elseifplayer/#/focus/https%3A%2F%2Fmirror.ifarchive.org%2Fif-archive%2Fgames%2Fzcode%2FLostPig.z8/)
- [Play "Lost Pig" with Nord theme](https://he4et.github.io/elseifplayer/#/play/https%3A%2F%2Fmirror.ifarchive.org%2Fif-archive%2Fgames%2Fzcode%2FLostPig.z8/nord/)
- [Play "Lost Pig" without statusbar with Dim theme](https://he4et.github.io/elseifplayer/#/focus/https%3A%2F%2Fmirror.ifarchive.org%2Fif-archive%2Fgames%2Fzcode%2FLostPig.z8/dim/)
- [Play "Lost Pig" loaded with Parchment Proxy](https://he4et.github.io/elseifplayer/#/play/https%3A%2F%2Fiplayif.com%2Fproxy%2F%3Furl%3Dhttps%3A%2F%2Fifarchive.org%2Fif-archive%2Fgames%2Fzcode%2FLostPig.z8)
## License
ElseIFPlayer is distributed under the MIT License.
However, please remember to respect the licenses of the interpreters
listed on the
[Emglken page](https://github.com/curiousdannii/emglken#included-projects).

View file

@ -2,7 +2,7 @@
CURRENT_TIMESTAMP=`date +"%Y-%m-%d-%H%M%S"`
GH_REPO_NAME='ifplayer'
GH_REPO_NAME='elseifplayer'
RELEASE_BRANCH='release'
BUILD_DIR='docs'

View file

@ -4,33 +4,25 @@
<meta charset="UTF-8">
<meta
name="viewport"
content="width=device-width, initial-scale=1.0">
content="width=device-width, initial-scale=1.0, interactive-widget=resizes-content">
<title>
IFPlayer
ElseIFPlayer
</title>
<meta
name="description"
content="Interactive Fiction player for the web.">
content="Interactive Fiction player for the web">
</head>
<body>
<div id="root">
<div id="root"></div>
<div class="app play">
<main>
<div class="status loading">
<div>Loading</div>
</div>
</main>
</div>
</div>
<script src="./src/index.js"></script>
<script type="module" src="./src/index.js"></script>
<!-- <goatcounter> -->
<script>
window.goatcounter = { no_onload: true }
window.addEventListener('hashchange', _ => {
if (!window.goatcounter.count) return void null
window.goatcounter.count({
path: location.pathname + location.hash
})

13873
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,35 +1,44 @@
{
"name": "ifplayer",
"version": "0.1.0",
"name": "elseifplayer",
"version": "0.2.0",
"description": "Play interactive fiction games in your browser",
"main": "index.js",
"scripts": {
"dev": "parcel index.html",
"build": "parcel build index.html --out-dir docs --public-url",
"build": "parcel build index.html --dist-dir docs --public-url",
"lint": "eslint --fix src"
},
"author": "He4eT",
"license": "MIT",
"browserslist": "defaults",
"engines": {
"node": ">=14.0.0"
},
"alias": {
"preact/jsx-dev-runtime": "preact/jsx-runtime"
},
"devDependencies": {
"eslint": "^7.20.0",
"eslint-config-standard": "^16.0.2",
"eslint-config-standard-preact": "^1.1.6",
"eslint-plugin-import": "^2.22.1",
"eslint-plugin-node": "^11.1.0",
"eslint-plugin-promise": "^4.3.1",
"parcel-bundler": "^1.12.4",
"parcel-plugin-static-files-copy": "^2.5.1"
"@parcel/transformer-sass": "^2.9.3",
"buffer": "^6.0.3",
"crypto-browserify": "^3.12.0",
"eslint": "^8.44.0",
"eslint-config-preact": "^1.3.0",
"events": "^3.3.0",
"parcel": "^2.9.3",
"parcel-reporter-static-files-copy": "^1.5.0",
"path-browserify": "^1.0.1",
"process": "^0.11.10",
"stream-browserify": "^3.0.0"
},
"dependencies": {
"@fontsource/open-sans": "^4.2.1",
"cheap-glkote": "^0.2.5",
"emglken": "^0.3.3",
"lz-string": "^1.4.4",
"preact": "^10.5.12",
"@fontsource/open-sans": "^5.0.3",
"base32768": "^3.0.1",
"cheap-glkote": "^0.5.1",
"emglken": "^0.5.2",
"preact": "^10.15.1",
"wouter-preact": "^2.7.3"
},
"staticFiles": {
"staticPath": "node_modules/emglken/build",
"excludeGlob": "*.js"
"staticOutPath": "emglken"
}
}

76
src/App.jsx Normal file
View file

@ -0,0 +1,76 @@
import { Route, Router, Switch } from 'wouter-preact'
import {
useHashLocation,
extractView,
} from './routing'
import {
useThemeEngine,
} from '~/src/themes/themes'
import HomeView from '~/src/views/HomeView/HomeView'
import GamesView from '~/src/views/GamesView/GamesView'
import ThemesView from '~/src/views/ThemesView/ThemesView'
import PlayerView from '~/src/views/PlayerView/PlayerView'
import NotFoundView from '~/src/views/NotFoundView/NotFoundView'
import * as s from './style/App.module.scss'
export default function App () {
const themeEngine = useThemeEngine()
const [currentLocation] = useHashLocation()
const playerView = (themeEngine, singleWindow) =>
function view (params) {
return (<PlayerView {...{
themeEngine,
singleWindow,
...params,
}} />)
}
return (
<Router hook={useHashLocation}>
<div className={[
s.app,
s[extractView(currentLocation)],
themeEngine.currentTheme,
].join(' ')}>
<Switch>
<Route path='/'>
<HomeView {...{
themeEngine,
}} />
</Route>
<Route path='/games/'>
<GamesView />
</Route>
<Route path='/themes/'>
<ThemesView {...{
themeEngine,
}} />
</Route>
<Route path='/play/:encodedUrl'>
{ playerView(themeEngine, false) }
</Route>
<Route path='/play/:encodedUrl/:theme'>
{ playerView(themeEngine, false) }
</Route>
<Route path='/focus/:encodedUrl'>
{ playerView(themeEngine, true) }
</Route>
<Route path='/focus/:encodedUrl/:theme'>
{ playerView(themeEngine, true) }
</Route>
<Route>
<NotFoundView />
</Route>
</Switch>
</div>
</Router>
)
}

View file

@ -1,6 +1,4 @@
import { h } from 'preact'
export default function ({ theme, setLocation, buildLink }) {
export default function LocalFileSelector ({ theme, setLocation, buildLink }) {
const fileInputHandler = ({ target }) => {
const file = target.files[0]
const url = `${URL.createObjectURL(file)}#${file.name}`

View file

@ -1,12 +1,10 @@
import { h } from 'preact'
export default function ({ theme, setLocation, buildLink }) {
export default function TargetURLSelector ({ theme, setLocation, buildLink }) {
const urlRE = /^(http|https):\/\/[^ "]+$/
const onKeyPress = ({ keyCode, target }) => {
if (keyCode !== 13) return
const url = target.value
const url = encodeURI(target.value)
if (urlRE.test(url)) {
setLocation(buildLink({ url, theme }))

View file

@ -1,23 +1,24 @@
import { h } from 'preact'
import { Link } from 'wouter-preact'
import {
buildPlayLinkHref
} from '~/src/utils/utils.routing'
buildPlayLinkHref,
} from '~/src/routing'
export default ({ name, ifdb, url }) => (
<div>
<h4>{name}</h4>
<a
target='_blank'
rel='noopener'
href={ifdb}>
IFDB page
</a>
<span> | </span>
<Link
href={buildPlayLinkHref({ url })}>
Play
</Link>
</div>
)
export default function GameEntry ({ name, ifdb, url }) {
return (
<div>
<h4>{name}</h4>
<a
target='_blank'
rel='noopener noreferrer'
href={ifdb}>
IFDB page
</a>
<span> | </span>
<Link
href={buildPlayLinkHref({ url })}>
Play
</Link>
</div>
)
}

View file

@ -1,108 +0,0 @@
import { h } from 'preact'
import { useEffect, useRef, useState } from 'preact/hooks'
/* eslint-disable */
const keyCodes = {
KEY_BACKSPACE: 8,
KEY_TAB: 9,
KEY_RETURN: 13,
KEY_ESC: 27,
KEY_PAGEUP: 33,
KEY_PAGEDOWN: 34,
KEY_END: 35,
KEY_HOME: 36,
KEY_LEFT: 37,
KEY_UP: 38,
KEY_RIGHT: 39,
KEY_DOWN: 40
}
const keyNames = {
[keyCodes.KEY_BACKSPACE]: 'delete',
[keyCodes.KEY_TAB]: 'tab',
[keyCodes.KEY_RETURN]: 'return',
[keyCodes.KEY_ESC]: 'escape',
[keyCodes.KEY_PAGEUP]: 'pageup',
[keyCodes.KEY_PAGEDOWN]: 'pagedown',
[keyCodes.KEY_END]: 'end',
[keyCodes.KEY_HOME]: 'home',
[keyCodes.KEY_LEFT]: 'left',
[keyCodes.KEY_UP]: 'up',
[keyCodes.KEY_RIGHT]: 'right',
[keyCodes.KEY_DOWN]: 'down'
}
/* eslint-enable */
export default function ({ currentWindow, inputType, sendMessage }) {
const [inputText, setInputText] = useState('')
const [lastInput, setLastInput] = useState('')
const inputEl = useRef(null)
useEffect(() => {
setInputText('')
inputEl.current && inputEl.current.focus()
}, [inputType])
const send = x => {
sendMessage(x, currentWindow)
setLastInput(x)
setInputText('')
}
const charHandler = event => {
event.preventDefault()
const key =
keyNames[event.keyCode] ||
event.key
send(key)
}
const lineHandler = ({ keyCode, target: { value } }) => {
if (keyCode === keyCodes.KEY_RETURN) {
send(value)
}
}
const lineArrowHandler = ({ keyCode }) => {
if (keyCode === keyCodes.KEY_UP) {
setInputText(lastInput)
setTimeout(_ => {
const end = lastInput.length
inputEl.current.setSelectionRange(end, end)
}, 0)
}
if (keyCode === keyCodes.KEY_DOWN) {
setInputText('')
}
}
const inputHandlers = {
char: {
placeholder: 'Press any key here',
onKeyDown: charHandler
},
line: {
placeholder: ' > ',
onKeyDown: lineArrowHandler,
onKeyPress: lineHandler
}
}
const enterFullscreen = _ =>
document.documentElement.requestFullscreen()
return (
<input {...inputHandlers[inputType]}
className='inputBox'
ref={inputEl}
value={inputText}
autofocus
autocomplete='off'
onDblClick={enterFullscreen}
onInput={({ target: { value } }) => setInputText(value)}
type='search' />
)
}

View file

@ -0,0 +1,169 @@
import { useEffect, useRef, useState } from 'preact/hooks'
import MenuButton from './MenuButton/MenuButton'
import * as s from './InputBox.module.scss'
/* eslint-disable */
const keyCodes = {
KEY_BACKSPACE: 8,
KEY_TAB: 9,
KEY_RETURN: 13,
KEY_ESC: 27,
KEY_PAGEUP: 33,
KEY_PAGEDOWN: 34,
KEY_END: 35,
KEY_HOME: 36,
KEY_LEFT: 37,
KEY_UP: 38,
KEY_RIGHT: 39,
KEY_DOWN: 40
}
const keyNames = {
[keyCodes.KEY_BACKSPACE]: 'delete',
[keyCodes.KEY_TAB]: 'tab',
[keyCodes.KEY_RETURN]: 'return',
[keyCodes.KEY_ESC]: 'escape',
[keyCodes.KEY_PAGEUP]: 'pageup',
[keyCodes.KEY_PAGEDOWN]: 'pagedown',
[keyCodes.KEY_END]: 'end',
[keyCodes.KEY_HOME]: 'home',
[keyCodes.KEY_LEFT]: 'left',
[keyCodes.KEY_UP]: 'up',
[keyCodes.KEY_RIGHT]: 'right',
[keyCodes.KEY_DOWN]: 'down'
}
/* eslint-enable */
const hasModifier = (event) => {
const modifiers = [
event.altKey,
event.ctrlKey,
event.metaKey,
event.shiftKey,
]
return modifiers.some((modifier) => modifier === true)
}
export default function InputBox ({
inputType,
windows,
currentWindowId,
sendMessage,
onFullscreenRequest,
setMenuOpen,
}) {
const [targetWindow, setTargetWindow] = useState(null)
const [inputText, setInputText] = useState('')
const [lastInput, setLastInput] = useState('')
const inputEl = useRef(null)
useEffect(() => {
let setFocus = () => {
inputEl.current && inputEl.current.focus()
}
setInputText('')
setFocus()
document.addEventListener('fullscreenchange', setFocus)
return () => document.removeEventListener('fullscreenchange', setFocus)
}, [inputType])
useEffect(() => {
setTargetWindow(
windows
.find(({ id }) =>
id === currentWindowId))
}, [currentWindowId, windows])
const send = (message) => {
sendMessage(
message,
inputType,
targetWindow)
setLastInput(message)
setInputText('')
}
const charHandler = (event) =>
(event.keyCode === 229
? charHandlerMobile
: charHandlerDefault)(event)
const charHandlerDefault = (event) => {
if (hasModifier(event)) { return undefined }
event.preventDefault()
const key =
keyNames[event.keyCode] ||
event.key
send(key)
}
const charHandlerMobile = (event) =>
setTimeout(() => {
send(event.target.value.slice(-1).toUpperCase())
setInputText('')
})
const lineHandler = ({ keyCode, target: { value } }) => {
if (keyCode === keyCodes.KEY_RETURN) {
send(value)
}
}
const lineArrowHandler = ({ keyCode }) => {
if (keyCode === keyCodes.KEY_UP) {
setInputText(lastInput)
setTimeout(() => {
const end = lastInput.length
inputEl.current.setSelectionRange(end, end)
}, 0)
}
if (keyCode === keyCodes.KEY_DOWN) {
setInputText('')
}
}
const inputHandlers = {
char: {
maxlength: '1',
placeholder: 'Press any key here',
onKeyDown: charHandler,
},
line: {
placeholder: ' > ',
onKeyDown: lineArrowHandler,
onKeyPress: lineHandler,
},
finished: {
placeholder: 'The program has finished',
disabled: true,
},
}
return (
<section className={s.inputControls}>
<input {...inputHandlers[inputType]}
className={s.inputBox}
ref={inputEl}
value={inputText}
autofocus
autocomplete='off'
spellCheck='false'
autocapitalize='off'
autocorrect='off'
onDblClick={onFullscreenRequest}
onInput={({ target: { value } }) => setInputText(value)}
type='search' />
<MenuButton
onClick={() => setMenuOpen(true)} />
</section>
)
}

View file

@ -0,0 +1,31 @@
.inputControls {
position: relative;
margin-top: var(--input-box-margin);
.inputBox {
font: inherit;
color: inherit;
outline: 0;
background-color: var(--bg-color);
border: var(--border-width) solid var(--main-color);
border-top: var(--separator-width) solid var(--main-color);
padding: var(--inner-padding);
padding-right: calc(4 * var(--inner-padding));
margin: 0;
width: 100%;
&::placeholder {
color: var(--main-color);
opacity: 1;
}
&:focus::placeholder {
opacity: 0.5;
}
&::-webkit-search-cancel-button {
display: none;
}
}
}

View file

@ -0,0 +1,21 @@
import * as s from './MenuButton.module.scss'
export default function MenuButton ({ onClick }) {
return (
<button
aria-label='Menu'
className={s.menuButton}
onClick={onClick}
>
<svg
class={s.menuIcon}
viewBox='0 0 28 32'
xmlns='http://www.w3.org/2000/svg'
>
<rect x='12' y='4' width='4' height='4' />
<rect x='12' y='14' width='4' height='4' />
<rect x='12' y='24' width='4' height='4' />
</svg>
</button>
)
}

View file

@ -0,0 +1,33 @@
.menuButton {
display: inline-flex;
align-items: center;
justify-content: center;
background: none;
border: none;
outline-offset: -8px;
position: absolute;
right: 0;
height: 100%;
padding: 0;
@media (max-width: 800px) {
padding: 0 calc(0.5 * var(--inner-padding));
overflow: hidden;
}
&:focus-visible {
outline-offset: -4px;
}
.menuIcon {
height: 32px;
fill: none;
stroke: currentColor;
stroke-width: 2px;
@media (max-width: 800px) {
transform: scaleX(2) scaleY(0.5);
fill: currentColor;
}
}
}

View file

@ -0,0 +1,79 @@
import { useEffect, useRef } from 'preact/hooks'
import { Link } from 'wouter-preact'
import ThemeSelector from
'~/src/components/ThemeSelector/ThemeSelector'
import * as s from './MenuOverlay.module.scss'
export default function MenuOverlay ({
themeEngine, onFullscreenRequest, menuOpen, setMenuOpen,
}) {
const dialog = useRef(null)
useEffect(() => {
const dialogOpen = dialog.current.open
if (menuOpen && !dialogOpen) {
dialog.current.showModal()
}
if (!menuOpen && dialogOpen) {
dialog.current.close()
}
}, [menuOpen])
useEffect(() => {
const currentDialog = dialog.current
const closeHandler = () => {
setMenuOpen(false)
}
currentDialog.addEventListener('close', closeHandler)
return () => currentDialog.removeEventListener('close', closeHandler)
}, [dialog, setMenuOpen])
return (
<dialog ref={dialog} className={s.menu}>
<section>
<div>
<button
tabIndex={0}
onClick={() => dialog.current.close()}
>
Close this menu
</button>
</div>
<div className={s.appearance}>
<button
onClick={() => {
dialog.current.close()
onFullscreenRequest()
}}
>
Full screen
</button>
<button
onClick={() => themeEngine.setRandomTheme()}
>
Set a random theme
</button>
<label>
Current theme:
<ThemeSelector {...{
themeEngine,
}} />
</label>
</div>
<div className={s.navigation}>
<Link href="/" tabIndex={0}>
ElseIfPlayer
</Link>
</div>
</section>
</dialog>
)
}

View file

@ -0,0 +1,41 @@
.menu {
width: 100%;
border-left: none;
border-right: none;
text-align: center;
padding-top: 0;
padding-bottom: 0;
background-color: var(--bg-color);
border-color: var(--main-color);
color: var(--main-color);
&::backdrop {
background: none;
backdrop-filter: blur(2px);
}
& > section {
margin: 32px auto 40px;
gap: 32px;
max-width: 270px;
display: flex;
flex-direction: column;
}
.navigation {
color: var(--accent-color);
}
.appearance {
display: flex;
flex-direction: column;
gap: 8px;
}
select,
button {
width: 100%;
}
}

View file

@ -0,0 +1,79 @@
import { useEffect, useState } from 'preact/hooks'
import TextMessage from '../TextMessage/TextMessage'
import * as s from '../../Player.module.scss'
export default function GridBuffer ({ inbox, currentWindow }) {
const [prevMessages, setPrevMessages] = useState([])
const [messages, setMessages] = useState([])
useEffect(() => {
const currentInboxObj =
inbox.find(({ id }) =>
id === currentWindow.id)
const currentInbox = currentInboxObj?.lines ?? []
const newOrPrev = (cur, prev) => (i) => {
const byId = (list, i) =>
list.find(({ line }) => line === i)
return byId(cur, i) || byId(prev, i)
}
const rawMessages =
Array(currentWindow.gridheight)
.fill(null)
.map((_, i) => i)
.map(newOrPrev(currentInbox, prevMessages))
/* */
const shouldUpdatePrev = (rawMessages, prevMessages) => {
const serialize = JSON.stringify
return serialize(rawMessages) !== serialize(prevMessages)
}
if (shouldUpdatePrev(rawMessages, prevMessages)) {
setPrevMessages(rawMessages)
}
/* */
const rawMessagesContent =
rawMessages
.map((x) => x.content)
.flat()
.map((message) => ({
...message,
text: message.text.trim(),
}))
const isEmpty =
rawMessagesContent
.map(({ text }) => text.length)
.every((l) => l === 0)
const getGridStyle = ({ style }) => {
if (['alert', 'normal'].includes(style)) return 'grid'
return style || 'grid'
}
const messages =
rawMessagesContent
.map((message) => ({
style: getGridStyle(message),
text: message.text.replace(' ', ' / '),
}))
setMessages(isEmpty ? [] : messages)
}, [inbox, currentWindow, prevMessages])
return (
<section className={[s.buffer, s.gridBuffer].join(' ')}>
{messages.map(TextMessage)}
</section>
)
}

View file

@ -0,0 +1,97 @@
import { useEffect, useRef, useState } from 'preact/hooks'
import TextMessage from '../TextMessage/TextMessage'
import * as s from '../../Player.module.scss'
const eol = { style: 'endOfLine' }
const scrollTarget = { style: 'scrollTarget' }
const isFakeStatus = (w) =>
w.height < 5
const trimInputPrompt = (messages) =>
messages.length < 1
? messages
: messages.slice(-1)[0].text === '>'
? messages.slice(0, messages.length - 1)
: messages
const parseInbox = (inbox, currentWindow) => {
const currentInbox =
inbox.find(({ id }) =>
id === currentWindow.id)
if (!currentInbox) {
return {
clear: false,
incoming: [scrollTarget],
}
}
const { text: inboxMessagesRaw } =
currentInbox
const incoming =
inboxMessagesRaw
/* Normalize. */
.map(({ content }) =>
content
? [...trimInputPrompt(content), eol]
: [eol])
/* Flatten. */
.reduce((acc, x) =>
acc.concat(x), [scrollTarget])
return {
incoming,
clear: isFakeStatus(currentWindow)
? true
: currentInbox.clear,
}
}
export default function TextBuffer ({ inbox, currentWindow }) {
const [messages, setMessages] = useState([])
const textBufferEl = useRef(null)
useEffect(() => {
const { incoming, clear } =
parseInbox(inbox, currentWindow)
setMessages((messages) => clear
? incoming
: messages.concat(incoming))
setTimeout(() => {
const scrollTargets =
textBufferEl.current.querySelectorAll(`.${scrollTarget.style}`)
const freshScrollTarget =
scrollTargets[scrollTargets.length - 1]
freshScrollTarget
? freshScrollTarget.scrollIntoView()
: textBufferEl.current.scrollTo({
top: textBufferEl.current.scrollHeight,
behavior: 'smooth',
})
}, 0)
}, [currentWindow, inbox])
const classes = () => [
s.buffer,
isFakeStatus(currentWindow)
? s.gridBuffer
: s.textBuffer,
].join(' ')
return (
<section
tabindex='0'
ref={textBufferEl}
className={classes()}
>
{messages.map(TextMessage)}
</section>
)
}

View file

@ -0,0 +1,23 @@
import * as s from './TextMessage.module.scss'
export default function TextMessage ({ style, text }) {
const defaultContent = (
<span className={[s.message, s[style]].join(' ')}>
{text}
</span>)
return ({
grid:
(text?.length > 0 ? <div>{text}</div> : <br />),
input:
(<span className={[s.message, s.input].join(' ')}>&gt; {text}</span>),
subheader:
(<strong className={[s.message, s.subheader].join(' ')}>{text}</strong>),
emphasized:
(<em className={[s.message, s.emphasized].join(' ')}>{text}</em>),
scrollTarget:
(<div className={[s.scrollTarget, style].join(' ')}></div>),
endOfLine:
(<br />),
})[style] || defaultContent
}

View file

@ -0,0 +1,14 @@
.message {
&.input {
color: var(--input-color);
}
&.emphasized,
&.subheader {
color: var(--accent-color);
}
}
.scrollTarget {
scroll-margin-block-start: var(--inner-padding);
}

View file

@ -1,71 +1,51 @@
import { h } from 'preact'
import { useState, useEffect } from 'preact/hooks'
import {
compressToUTF16 as encode,
decompressFromUTF16 as decode
} from 'lz-string'
import CheapGlkOte from 'cheap-glkote'
import TextBuffer from './TextBuffer'
import InputBox from './InputBox'
import Status from './Status'
import TextBuffer from './OutputBox/TextBuffer/TextBuffer'
import GridBuffer from './OutputBox/GridBuffer/GridBuffer'
import './player.css'
import InputBox from './InputBox/InputBox'
import Status from './Status/Status'
import {
Handlers,
unhandledRejectionHandler,
} from './common/playerHandlers'
import * as s from './Player.module.scss'
const INITIAL_STATUS = {
stage: 'loading',
details: ['Preparing']
details: ['Preparing'],
}
const runMachine = ({ engine: Engine, file, handlers }) => {
const vm = new Engine()
const { glkInterface, sendFn } = CheapGlkOte(handlers)
const runMachine = ({ engine: Engine, wasmBinary, storyfile, handlers }) => {
const { Dialog, GlkOte, send } = CheapGlkOte(handlers)
const instance = new Engine()
vm.prepare(file, glkInterface)
vm.start()
instance.init(storyfile, {
Dialog,
GlkOte,
Glk: {},
wasmBinary,
arguments: ['storyfile'],
})
instance.start()
return { sendFn, instance: vm }
return { send, instance }
}
const Handlers = ({
setStatus,
setCurrentWindow,
setInputType,
setInbox
}) => ({
onInit: _ => setStatus({ stage: 'ready' }),
/* */
onUpdateWindows: windows => {
setCurrentWindow(windows
.filter(x => x.type === 'buffer')
.slice(-1)[0])
},
onUpdateInputs: setInputType,
onUpdateContent: setInbox,
onDisable: _ => setInputType(null),
/* */
onFileNameRequest: (tosave, usage, _, setFileName) => {
setFileName({
usage,
filename: prompt('Enter the filename')
})
},
onFileRead: ({ filename }) => {
const content = localStorage.getItem(`fake-fs/${filename}`)
return decode(content)
},
onFileWrite: ({ filename }, content) => {
localStorage.setItem(`fake-fs/${filename}`, encode(content))
},
/* */
onExit: _ => setInputType(null)
})
export default function ({ vmParts: { file, engine } }) {
export default function Player ({
vmParts: { storyfile, engine, wasmBinary },
onFullscreenRequest,
setMenuOpen,
singleWindow,
}) {
const [status, setStatus] = useState(INITIAL_STATUS)
const [currentWindow, setCurrentWindow] = useState(null)
const [windows, setWindows] = useState([])
const [currentWindowId, setCurrentWindowId] = useState(null)
const [inputType, setInputType] = useState(null)
const [inbox, setInbox] = useState([])
@ -73,39 +53,75 @@ export default function ({ vmParts: { file, engine } }) {
const [sendMessage, setSendMessage] = useState(null)
useEffect(() => {
const vm = runMachine({
engine,
file,
handlers: Handlers({
setStatus,
setCurrentWindow,
setInputType,
setInbox
})
const handlers = Handlers({
setStatus,
setWindows,
setCurrentWindowId,
setInputType,
setInbox,
})
setVm(vm)
}, [file, engine])
setVm(runMachine({
engine,
wasmBinary,
storyfile,
handlers,
}))
const rejectionHandler =
unhandledRejectionHandler(handlers.onExit)
window.addEventListener('unhandledrejection', rejectionHandler)
return () => {
setVm(null)
window.removeEventListener('unhandledrejection', rejectionHandler)
}
}, [storyfile, engine, wasmBinary])
useEffect(() => {
setSendMessage(_ => vm
? vm.sendFn
setSendMessage(() => vm
? vm.send
: null)
return () => setSendMessage(null)
}, [vm])
const textWindow = (inbox) => (currentWindow) => {
const props = {
inbox,
currentWindow,
}
return ({
buffer: <TextBuffer {...props} />,
grid: <GridBuffer {...props} />,
})[currentWindow.type]
}
const byTop = (a, b) =>
a.top - b.top
return status.stage !== 'ready'
? (<Status {...status} />)
: (
<section className='ifplayer'>
<TextBuffer {...{
inbox,
currentWindow
}} />
<InputBox {...{
currentWindow,
inputType,
sendMessage
}} />
: (<section className={s.elseifplayer}>
<section className={s.output}>
{
windows
.sort(byTop)
.filter(singleWindow
? ({ id }) => id === currentWindowId
: () => true)
.map(textWindow(inbox))
}
</section>
)
<InputBox {...{
inputType,
windows,
currentWindowId,
sendMessage,
onFullscreenRequest,
setMenuOpen,
}} />
</section>)
}

View file

@ -0,0 +1,49 @@
.elseifplayer {
height: 100%;
display: flex;
flex-direction: column;
box-sizing: border-box;
background-color: var(--bg-color);
color: var(--main-color);
padding: var(--outer-padding);
.output {
display: flex;
flex-grow: 2;
flex-direction: column;
overflow-y: hidden;
overflow-wrap: break-word;
border: var(--border-width) solid var(--main-color);
.buffer {
overflow-y: scroll;
box-sizing: border-box;
padding: var(--inner-padding);
&:empty {
display: none;
}
& > br:first-child,
& > br:last-child,
& > br + br + br {
display: none;
}
&.gridBuffer {
flex-shrink: 0;
max-height: 100%;
border-bottom: var(--separator-width) solid var(--main-color);
}
&.textBuffer {
flex: 2 1;
outline: none;
scroll-behavior: smooth;
}
}
}
}

View file

@ -1,31 +0,0 @@
import { h } from 'preact'
import { Link } from 'wouter-preact'
const fail = details => (
<div class='status fail'>
<h1>
Error
</h1>
{details.map(x => (<p>{x}</p>))}
<hr />
<Link href='/'>
Home
</Link>
|
<a
target='_blank'
rel='noopener'
href='https://github.com/He4eT/ifplayer/issues'>
Report bug
</a>
</div>
)
const loading = details => (
<div class='status loading'>
{details.map(x => (<div>{x}</div>))}
</div>
)
export default ({ stage, details }) =>
({ fail, loading })[stage](details)

View file

@ -0,0 +1,33 @@
import { Link } from 'wouter-preact'
import * as s from './Status.module.scss'
const fail = (details) => (
<div className={[s.status].join(' ')}>
<h1>
Error
</h1>
{details.map((x) => (<p key={x}>{x}</p>))}
<hr />
<Link href='/'>
Home
</Link>
|
<a
target='_blank'
rel='noopener noreferrer'
href='https://github.com/He4eT/elseifplayer/issues'
>
Report bug
</a>
</div>
)
const loading = (details) => (
<div className={[s.status, s.loading].join(' ')}>
{details.map((x) => (<div key={x}>{x}</div>))}
</div>
)
export default ({ stage, details }) =>
({ fail, loading })[stage](details)

View file

@ -0,0 +1,21 @@
@keyframes dots0123 {
0% { content: ''; }
33% { content: '.'; }
66% { content: '..'; }
100% { content: '...'; }
}
.status {
word-break: break-word;
padding-block: var(--inner-padding);
@media (max-width: 800px) {
padding: var(--inner-padding);
}
&.loading > div:after {
animation: dots0123 1s infinite;
content: '';
}
}

View file

@ -1,76 +0,0 @@
import { h } from 'preact'
import { useEffect, useRef, useState } from 'preact/hooks'
import TextMessage from './TextMessage'
const trimInputPrompt = messages =>
messages.length < 1
? messages
: messages.slice(-1)[0].text === '>'
? messages.slice(0, messages.length - 1)
: messages
const parseInbox = (inbox, currentWindow) => {
const currentInbox =
inbox.find(({ id }) =>
id === currentWindow.id)
if (!currentInbox) {
return {
clear: false,
incoming: []
}
}
const { clear, text: inboxMessagesRaw } =
currentInbox
const eol = { style: 'endOfLine' }
const incoming =
inboxMessagesRaw
/* Normalize. */
.map(({ content }) =>
content
? [...trimInputPrompt(content), eol]
: [eol])
/* Flatten. */
.reduce((acc, x) =>
acc.concat(x), [])
return { clear, incoming }
}
export default function ({ inbox, currentWindow }) {
const [messages, setMessages] = useState([])
const textBufferEl = useRef(null)
useEffect(() => {
const { incoming, clear } =
parseInbox(inbox, currentWindow)
setMessages(clear
? incoming
: messages.concat(incoming))
setTimeout(() => {
const inputs =
textBufferEl.current.querySelectorAll('.message.input')
const lastInput =
inputs[inputs.length - 1]
textBufferEl.current.scrollTop =
lastInput
? lastInput.offsetTop
: textBufferEl.current.scrollHeight * 2
}, 0)
}, [inbox])
return (
<section
ref={textBufferEl}
className='textBuffer'>
{messages.map(TextMessage)}
</section>
)
}

View file

@ -1,15 +0,0 @@
import { h } from 'preact'
export default function ({ style, text }) {
const defaultContent = (
<span class={['message', style].join(' ')}>
{text}
</span>)
return ({
input: (<span class='message input'>&gt; {text}</span>),
subheader: (<strong>{text}</strong>),
emphasized: (<em>{text}</em>),
endOfLine: (<br />)
})[style] || defaultContent
}

View file

@ -1,41 +1,64 @@
import { h } from 'preact'
import { useState, useEffect } from 'preact/hooks'
import { engineByFilename } from './common/engines'
import Player from './Player'
import Status from './Status'
import Status from './Status/Status'
const INITIAL_STATUS = {
stage: 'loading',
details: ['Loading']
details: ['Loading'],
}
const prepareVM = ({ url, setStatus, setParts }) => {
const st = (stage, details) => args => {
const st = (stage, details) => (args) => {
setStatus({ stage, details: [details] })
return args
}
return Promise.resolve()
const cleanUrl = (url) =>
url.startsWith('blob:')
? url.replace(/#(.*)$/g, '')
: url
const fetchWasm = (wasmBinaryName) =>
fetch(wasmBinaryName)
.then((response) => response.arrayBuffer())
const checkResponse = (response) => {
if (response.ok) return response
throw new Error(response.statusText)
}
return Promise.resolve(url)
.then(st('loading', 'Downloading file'))
.then(_ => fetch(url))
.then(cleanUrl)
.then(fetch)
.then(checkResponse)
.then(st('loading', 'Processing file'))
.then(response => response.arrayBuffer())
.then(arrayBuffer => new Uint8Array(arrayBuffer))
.then((response) => response.arrayBuffer())
.then((arrayBuffer) => new Uint8Array(arrayBuffer))
.then(st('loading', 'Downloading engine'))
.then(file => setParts({
file,
engine: engineByFilename(url)
.then((storyfile) => {
let parts = engineByFilename(url)
return [storyfile, parts.engine, parts.wasmBinaryName]
})
.then(([storyfile, engine, wasmBinaryName]) => Promise.all([
storyfile, engine, fetchWasm(wasmBinaryName),
]))
.then(([storyfile, engine, wasmBinary]) => setParts({
storyfile, engine, wasmBinary,
}))
.then(st('loading', 'Running'))
.catch(e => {
.catch((e) => {
console.error(e)
setStatus({ stage: 'fail', details: [e.message, url] })
})
}
export default function ({ url }) {
export default function UrlPlayer ({
url, singleWindow, onFullscreenRequest, setMenuOpen,
}) {
const [status, setStatus] = useState(INITIAL_STATUS)
const [vmParts, setParts] = useState(null)
@ -44,9 +67,16 @@ export default function ({ url }) {
setParts(null)
prepareVM({ url, setStatus, setParts })
return () => setParts(null)
}, [url])
return vmParts
? (<Player vmParts={vmParts} />)
? (<Player {...{
vmParts,
onFullscreenRequest,
setMenuOpen,
singleWindow,
}} />)
: (<Status {...status} />)
}

View file

@ -2,37 +2,46 @@ import bocfel from 'emglken/src/bocfel.js'
import git from 'emglken/src/git.js'
import hugo from 'emglken/src/hugo.js'
import tads from 'emglken/src/tads.js'
import scare from 'emglken/src/scare.js'
const formats = [
{
id: 'bocfel',
extensions: /z([3458]|blorb)$/,
engine: bocfel
engine: bocfel,
},
{
id: 'git',
extensions: /(gblorb|ulx)$/,
engine: git
engine: git,
},
{
id: 'hugo',
extensions: /hex$/,
engine: hugo
engine: hugo,
},
{
id: 'scare',
extensions: /taf$/,
engine: scare,
},
{
id: 'tads',
extensions: /(gam|t3)$/,
engine: tads
}
engine: tads,
},
]
export const engineByFilename = filename => {
const format = formats.find(x =>
export const engineByFilename = (filename) => {
const format = formats.find((x) =>
x.extensions.test(filename))
if (format) {
return format.engine
} else {
throw new Error('Unsupported file type')
return {
...format,
/* @see staticFiles in package.json */
wasmBinaryName: `emglken/${format.id}-core.wasm`,
}
}
throw new Error('Unsupported file type')
}

View file

@ -0,0 +1,57 @@
import { encode, decode } from 'base32768'
export const Handlers = ({
setStatus,
setWindows,
setCurrentWindowId,
setInputType,
setInbox,
}) => ({
onInit: () => {
setStatus({ stage: 'ready' })
},
/* */
onUpdateWindows: (windows) => {
setWindows(windows)
},
onUpdateInputs: (data) => {
if (data.length === 0) return null
const { type, id } = data[0]
setCurrentWindowId(id)
setInputType(type)
},
onUpdateContent: (inbox) => {
setInbox(inbox)
},
onDisable: () => {
setInputType(null)
},
/* */
onFileNameRequest: (_tosave, usage, _gameId, setFileName) => {
setFileName({
usage,
filename: prompt('Enter the filename'),
})
},
onFileRead: ({ filename }) => {
const content = localStorage.getItem(`fake-fs/${filename}`)
return decode(content)
},
onFileWrite: ({ filename }, content) => {
localStorage.setItem(`fake-fs/${filename}`, encode(content))
},
/* */
onExit: () => {
setInputType('finished')
},
})
export const unhandledRejectionHandler = (onExit) => (event) => {
if (event.reason.name === 'ExitStatus' || event.reason.message === 'Program terminated with exit(0)') {
onExit()
} else {
console.error('Unhandled rejection (promise: ', event.promise, ', reason: ', event.reason, ').')
}
event.preventDefault()
}

View file

@ -1,80 +0,0 @@
.ifplayer {
height: 100%;
display: flex;
flex-direction: column;
box-sizing: border-box;
background-color: var(--bg-color);
color: var(--main-color);
padding: var(--outer-padding);
}
.ifplayer .inputBox {
flex: 0 1 auto;
font: inherit;
color: inherit;
outline: 0;
background-color: var(--bg-color);
border: var(--border-width) solid var(--main-color);
padding: var(--inner-padding);
margin-top: var(--input-box-margin);
}
.ifplayer .inputBox::placeholder {
color: var(--main-color);
opacity: 1;
}
.ifplayer .inputBox:focus::placeholder {
opacity: 0.5;
}
.ifplayer .inputBox::-webkit-search-cancel-button {
display: none;
}
.ifplayer .textBuffer {
flex: 2 1 auto;
overflow-y: scroll;
box-sizing: border-box;
border: var(--border-width) solid var(--main-color);
padding: var(--inner-padding);
scrollbar-color: var(--main-color) var(--bg-color);
scrollbar-width: thin;
}
.ifplayer .textBuffer::-webkit-scrollbar {
width: 8px;
}
.ifplayer .textBuffer::-webkit-scrollbar-thumb {
background-color: var(--main-color);
border: 4px solid var(--bg-color);
border-left-width: 0px;
}
.ifplayer .textBuffer > br:first-child,
.ifplayer .textBuffer > br:last-child,
.ifplayer .textBuffer > br + br + br {
display: none;
}
.status {
padding: var(--inner-padding);
}
.status.loading > div:after {
animation: dots0123 1s infinite;
content: '';
}
@keyframes dots0123 {
0% { content: ''; }
33% { content: '.'; }
66% { content: '..'; }
100% { content: '...'; }
}

View file

@ -1,8 +1,8 @@
import { h } from 'preact'
export default function ({ themeEngine }) {
const options = themeEngine.themes.map(theme => (
<option value={theme}>
export default function ThemeSelector ({ themeEngine }) {
const options = themeEngine.themes.map((theme) => (
<option
key={theme}
value={theme}>
{theme}
</option>))

View file

@ -1,62 +1,10 @@
import { h, render } from 'preact'
import { Route, Router, Switch } from 'wouter-preact'
import {
useHashLocation,
extractView
} from '~/src/utils/utils.routing'
import {
useThemeEngine
} from '~/src/themes/themes'
import HomeView from '~/src/views/HomeView/HomeView'
import GamesView from '~/src/views/GamesView/GamesView'
import PlayerView from '~/src/views/PlayerView/PlayerView'
import NotFoundView from '~/src/views/NotFoundView'
import { render } from 'preact'
import '@fontsource/open-sans'
import '~/src/style/base.css'
function App () {
const themeEngine = useThemeEngine()
const [location] = useHashLocation()
import './style/base.scss'
import './style/controls.scss'
return (
<Router hook={useHashLocation}>
<div className={[
'app',
extractView(location),
themeEngine.currentTheme].join(' ')}>
<Switch>
<Route path='/'>
<HomeView {...{
themeEngine
}} />
</Route>
<Route path='/games/'>
<GamesView />
</Route>
<Route path='/play/:encodedUrl'>
{params => <PlayerView {...{
...themeEngine,
...params
}} />}
</Route>
<Route path='/play/:encodedUrl/:theme'>
{params => <PlayerView {...{
...themeEngine,
...params
}} />}
</Route>
<Route>
<NotFoundView />
</Route>
</Switch>
</div>
</Router>
)
}
import App from './App'
render(<App />, document.getElementById('root'))

42
src/routing.js Normal file
View file

@ -0,0 +1,42 @@
import {
useCallback, useEffect, useState,
} from 'preact/hooks'
const windowLocation = () =>
window.location.hash.replace('#', '') || '/'
export const buildPlayLinkHref = ({ url }) =>
`/#/play/${encodeURIComponent(url)}`
export const extractView = (location) => {
if (location === '/') return 'home'
const currentView = location.split('/').filter(Boolean)[0]
return currentView || ''
}
export const useHashLocation = () => {
const [currentLocation, setCurrentLocation] =
useState(windowLocation())
useEffect(() => {
const onHashChange = () => {
let newLocation = windowLocation()
if (newLocation !== currentLocation) {
setCurrentLocation(newLocation)
window.scrollTo(0, 0)
}
}
onHashChange()
window.addEventListener('hashchange', onHashChange)
return () => window.removeEventListener('hashchange', onHashChange)
}, [currentLocation, setCurrentLocation])
const navigate = useCallback((to) => {
window.location.hash = to.replace('#/', '')
}, [])
return [currentLocation, navigate]
}

49
src/style/App.module.scss Normal file
View file

@ -0,0 +1,49 @@
.app {
min-height: 100%;
box-sizing: border-box;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
color: var(--main-color);
background-color: var(--bg-color);
/* */
&.home, &.games, &.themes {
padding: var(--inner-padding);
/* Fix for Jumping Scrollbar Issue */
@media (min-width: 800px) {
padding-left: calc(100vw - 100% + var(--inner-padding));
}
}
/* Player view */
&.play, &.focus {
height: 100%;
max-height: 100dvh;
@media (min-width: 800px) {
& > main {
max-height: 90%;
margin: auto;
}
}
}
/* */
& > main {
flex: 1 1 auto;
height: 100%;
width: 100%;
box-sizing: border-box;
@media (min-width: 800px) {
margin: 5vh 0;
max-width: 800px;
}
}
}

View file

@ -1,92 +0,0 @@
/* Layout */
html, body {
margin: 0;
height: 100%;
font-family: 'Open Sans', sans-serif;
font-size: 18px;
line-height: 27px;
}
#root {
height: 100%;
}
.app {
min-height: 100%;
box-sizing: border-box;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
background-color: var(--bg-color);
color: var(--main-color);
}
.app > main {
flex: 1 1 auto;
height: 100%;
width: 100%;
box-sizing: border-box;
}
@media (min-width: 800px) {
.app > main {
margin: 5vh 0;
max-width: 800px;
}
}
/* Common */
a,
summary {
display: inline;
cursor: pointer;
border-bottom: 2px solid currentColor;
color: inherit;
text-decoration: none;
}
a:focus,
a:hover,
summary:focus,
summary:hover {
opacity: 0.8;
}
*:focus {
outline: 1px solid var(--main-color);
outline-offset: 3px;
}
*::selection {
color: var(--bg-color);
background: var(--main-color);
}
input::placeholder {
color: var(--main-color);
opacity: 0.8;
}
ul {
list-style: square;
}
hr {
border: 0;
height: 0;
border-top: 2px solid var(--main-color);
}
/* */
.status {
padding: 8px;
word-break: break-word;
}

17
src/style/base.scss Normal file
View file

@ -0,0 +1,17 @@
/* Layout */
html, body {
margin: 0;
height: 100%;
font-family: 'Open Sans', sans-serif;
font-size: 18px;
line-height: 27px;
/* To prevent the white flash */
background: #000000;
}
#root {
height: 100%;
}

128
src/style/controls.scss Normal file
View file

@ -0,0 +1,128 @@
/* Scrollbars */
body {
* {
scrollbar-color: var(--main-color) var(--bg-color);
scrollbar-width: thin;
}
*::-webkit-scrollbar {
width: var(--inner-padding);
}
*::-webkit-scrollbar-thumb {
background-color: var(--main-color);
border: calc(0.5 * var(--inner-padding)) solid var(--bg-color);
border-left-width: 0;
}
}
/* Focus */
*:focus-visible {
outline: calc(0.5 * var(--separator-width)) solid var(--main-color);
outline-offset: calc(1px + var(--separator-width));
}
*::selection {
color: var(--bg-color);
background: var(--main-color);
}
/* Links */
a,
summary {
display: inline;
cursor: pointer;
border-bottom: var(--separator-width) solid currentColor;
color: inherit;
text-decoration: none;
}
a:focus,
a:hover,
summary:focus,
summary:hover {
opacity: 0.8;
}
/* Separators */
hr {
border: 0;
height: 0;
border-top: var(--separator-width) solid var(--main-color);
}
/* Lists */
ul {
list-style: square;
}
/* Buttons */
button {
border: var(--separator-width) solid var(--main-color);
background-color: var(--bg-color);
color: var(--main-color);
padding: var(--inner-padding) calc(4 * var(--inner-padding));
font-family: inherit;
font-size: inherit;
cursor: pointer;
}
/* Inputs */
input::placeholder {
color: var(--main-color);
opacity: 0.8;
}
input,
select {
box-sizing: border-box;
padding: calc(0.5 * var(--inner-padding)) var(--inner-padding);
color: var(--main-color);
background-color: var(--bg-color);
border: var(--separator-width) solid var(--main-color);
font: inherit;
}
select {
width: 250px;
cursor: pointer;
appearance: none;
-webkit-appearance: none;
border-radius: 0;
background-image: repeating-linear-gradient(
315deg,
var(--bg-color),
var(--bg-color) var(--inner-padding),
var(--main-color) var(--inner-padding),
var(--main-color) calc(var(--inner-padding) + var(--separator-width)),
var(--bg-color) calc(var(--inner-padding) + var(--separator-width)),
var(--bg-color) 100%
);
}
label {
display: inline-block;
width: 100%;
max-width: 400px;
}
label input {
width: 100%;
}
input[type='file'] {
position: relative;
font-size: 0;
text-indent: -100%;
cursor: pointer;
}

27
src/themes/_generator.js Normal file
View file

@ -0,0 +1,27 @@
/* @see https://github.com/monkeytypegame/monkeytype/blob/master/frontend/static/themes/_list.json */
const monkeyTypesThemes = [
/* Paste json here! */
].map((theme) => theme)
.filter((theme) => ![
'dark', 'solarized_dark', 'solarized_light',
].includes(theme.name))
.sort((a, b) => a.name.localeCompare(b.name))
const names = monkeyTypesThemes
.map(({ name }) => `'${name}',`).join('\n')
const css = monkeyTypesThemes
.map((theme) => [
`.${theme.name} {`,
` --bg-color: ${theme.bgColor};`,
` --main-color: ${theme.textColor};`,
` --accent-color: ${theme.mainColor};`,
` --input-color: ${theme.subColor};`,
'}\n'].join('\n'))
.join('\n')
console.log('/* List*/')
console.log(names)
console.log('/* CSS */')
console.log(css)

179
src/themes/themeList.js Normal file
View file

@ -0,0 +1,179 @@
export const themes = [
'light',
'dim',
'dark',
/* Solarized */
'solarized-light',
'solarized-dark',
/* Original */
'emo',
'redrum',
'toxin',
'valve',
'wasp',
/* Monkeytype */
/* @see https://github.com/monkeytypegame/monkeytype/blob/master/frontend/static/themes/_list.json */
'a8008',
'a80s_after_dark',
'a9009',
'aether',
'alduin',
'alpine',
'arch',
'aurora',
'beach',
'bento',
'bingsu',
'bliss',
'blue_dolphin',
'blueberry_dark',
'blueberry_light',
'botanical',
'bouquet',
'breeze',
'bushido',
'cafe',
'camping',
'carbon',
'catppuccin',
'chaos_theory',
'cheesecake',
'cherry_blossom',
'comfy',
'copper',
'creamsicle',
'cyberspace',
'dark_magic_girl',
'dark_note',
'darling',
'deku',
'desert_oasis',
'dev',
'diner',
'dino',
'dmg',
'dollar',
'dots',
'dracula',
'drowning',
'dualshot',
'earthsong',
'everblush',
'evil_eye',
'ez_mode',
'fire',
'fledgling',
'fleuriste',
'froyo',
'frozen_llama',
'fruit_chew',
'fundamentals',
'future_funk',
'godspeed',
'graen',
'grand_prix',
'gruvbox_dark',
'gruvbox_light',
'hammerhead',
'hanok',
'hedge',
'honey',
'horizon',
'husqy',
'iceberg_dark',
'iceberg_light',
'ishtar',
'iv_clover',
'iv_spade',
'joker',
'laser',
'lavender',
'leather',
'lil_dragon',
'lime',
'luna',
'magic_girl',
'mashu',
'matcha_moccha',
'material',
'matrix',
'menthol',
'metaverse',
'metropolis',
'mexican',
'miami',
'miami_nights',
'midnight',
'milkshake',
'mint',
'mizu',
'modern_dolch',
'modern_dolch_light',
'modern_ink',
'monokai',
'moonlight',
'mountain',
'mr_sleeves',
'ms_cupcakes',
'muted',
'nautilus',
'nebula',
'night_runner',
'nord',
'nord_light',
'norse',
'oblivion',
'olive',
'olivia',
'onedark',
'our_theme',
'paper',
'passion_fruit',
'pastel',
'peach_blossom',
'peaches',
'pink_lemonade',
'pulse',
'purpurite',
'red_dragon',
'red_samurai',
'repose_dark',
'repose_light',
'retro',
'retrocast',
'rose_pine',
'rose_pine_dawn',
'rose_pine_moon',
'rudy',
'ryujinscales',
'serika',
'serika_dark',
'sewing_tin',
'sewing_tin_light',
'shadow',
'shoko',
'slambook',
'snes',
'soaring_skies',
'sonokai',
'stealth',
'strawberry',
'striker',
'superuser',
'sweden',
'taro',
'terminal',
'terra',
'terror_below',
'tiramisu',
'trackday',
'trance',
'tron_orange',
'vaporwave',
'viridescent',
'voc',
'vscode',
'watermelon',
'wavez',
'witch_girl',
]

File diff suppressed because it is too large Load diff

View file

@ -1,22 +1,9 @@
import { useState } from 'preact/hooks'
import { themes } from './themeList.js'
import './themes.css'
const themes = [
'light',
'dim',
'dark',
'solarized-light',
'solarized-dark',
'emo',
'nord',
'redrum',
'toxin',
'valve',
'wasp'
]
const LS_THEME_KEY = 'ifplayer/theme'
const LS_THEME_KEY = 'elseifplayer/theme'
const DEFAULT_THEME = themes[0]
const getSavedTheme = () => {
@ -24,7 +11,7 @@ const getSavedTheme = () => {
return savedTheme || DEFAULT_THEME
}
const assertTheme = theme =>
const assertTheme = (theme) =>
themes.includes(theme)
? theme
: getSavedTheme()
@ -33,12 +20,17 @@ export const useThemeEngine = (initialTheme = getSavedTheme()) => {
const [currentTheme, setCurrentTheme] =
useState(initialTheme)
const setTheme = theme => {
const setTheme = (theme) => {
const newTheme = assertTheme(theme)
setCurrentTheme(newTheme)
localStorage.setItem(LS_THEME_KEY, newTheme)
}
return { currentTheme, setTheme, themes }
const setRandomTheme = () => {
const randomTheme = themes[Math.floor(Math.random() * themes.length)]
setTheme(randomTheme)
}
return { currentTheme, setTheme, setRandomTheme, themes }
}

View file

@ -1,30 +0,0 @@
import {
useState, useEffect, useCallback
} from 'preact/hooks'
export const useHashLocation = () => {
const currentLoc = () =>
window.location.hash.replace('#', '') || '/'
const [loc, setLoc] = useState(currentLoc())
useEffect(() => {
const handler = () => setLoc(currentLoc())
window.addEventListener('hashchange', handler)
handler()
return () => window.removeEventListener('hashchange', handler)
}, [])
const navigate = useCallback(to =>
(window.location.hash = to.replace('#/', '')), [])
return [loc, navigate]
}
export const buildPlayLinkHref = ({ url }) =>
`/#/play/${encodeURIComponent(url)}`
export const extractView = location => {
const currentView = location.split('/').filter(Boolean)[0]
return currentView || ''
}

View file

@ -1,11 +0,0 @@
.app > .view.games {
padding: var(--inner-padding);
}
.view.games h4 {
margin: 0;
}
.view.games li {
margin-bottom: 1em;
}

View file

@ -1,4 +1,3 @@
import { h } from 'preact'
import { Link } from 'wouter-preact'
import GameEntry from
@ -6,26 +5,25 @@ import GameEntry from
import top2019 from './top2019'
import './GamesView.css'
import * as s from './GamesView.module.scss'
const tutorialGame = {
name: 'The Dreamhold',
ifdb: 'https://ifdb.org/viewgame?id=3myqnrs64nbtwdaz',
url: 'https://mirror.ifarchive.org/if-archive/games/zcode/dreamhold.z8'
url: 'https://mirror.ifarchive.org/if-archive/games/zcode/dreamhold.z8',
}
export default function () {
export default function GamesView () {
return (
<main className='view games'>
<main className={s.games}>
<h1>
<a
target='_blank'
rel='noopener'
rel='noopener noreferrer'
href='https://ifdb.org/'
title='The Interactive Fiction Database'>
IFDB
</a> games
</a> Games
</h1>
<p>
@ -33,25 +31,25 @@ export default function () {
go back</Link>.
</p>
<h2>
Tutorial
</h2>
<section className={s.tutorial}>
<h2>
Tutorial
</h2>
<p>
If you are not familiar with Interactive Fiction,
you should start with this tutorial game
by&nbsp;Andrew&nbsp;Plotkin:
</p>
<p>
If you are not familiar with Interactive Fiction,
you should start with this tutorial game
by&nbsp;Andrew&nbsp;Plotkin:
</p>
<ul>
<li>
<GameEntry {...{
...tutorialGame
}} />
</li>
</ul>
<br />
<ul>
<li>
<GameEntry {...{
...tutorialGame,
}} />
</li>
</ul>
</section>
<h2>
Interactive Fiction Top 50 of All Time
@ -60,23 +58,23 @@ export default function () {
<p>
<a
target='_blank'
rel='noopener'
rel='noopener noreferrer'
href='https://ifdb.org/search?comp&sortby=awn&searchfor=series%3AInteractive+Fiction+Top+50+of+All+Time'>
Every four years </a>, Victor Gijsbers puts
together a list of the top 50 IF games of all time.
Here is an almost complete version of the <a
Here is an almost complete and slightly rearranged version of the <a
target='_blank'
rel='noopener'
rel='noopener noreferrer'
href='https://ifdb.org/viewcomp?id=1lv599reviaxvwo7'>
list for 2019</a>:
list from 2019</a>:
</p>
<ol>
{top2019.map(game => (
<li>
{top2019.map((game) => (
<li key={game.name}>
<GameEntry {...{
...game
...game,
}} />
</li>
))}

View file

@ -0,0 +1,13 @@
.games {
.tutorial {
margin-block: 64px;
}
h4 {
margin: 0;
}
li {
margin-bottom: 1em;
}
}

View file

@ -1,20 +1,20 @@
export default [
[
'Lost Pig',
'https://ifdb.org/viewgame?id=mohwfk47yjzii14w',
'https://mirror.ifarchive.org/if-archive/games/zcode/LostPig.z8',
],
[
/* Check with cheap-glk */
'Counterfeit Monkey',
'https://ifdb.org/viewgame?id=aearuuxv83plclpl',
'https://mirror.ifarchive.org/if-archive/games/glulx/CounterfeitMonkey.gblorb'
],
[
'Lost Pig',
'https://ifdb.org/viewgame?id=mohwfk47yjzii14w',
'https://mirror.ifarchive.org/if-archive/games/zcode/LostPig.z8'
'https://mirror.ifarchive.org/if-archive/games/glulx/CounterfeitMonkey.gblorb',
],
[
/* Works. Check inputs */
'Anchorhead',
'https://ifdb.org/viewgame?id=op0uw1gn1tjqmjt7',
'https://ifarchive.org/if-archive/games/zcode/anchor.z8'
'https://ifarchive.org/if-archive/games/zcode/anchor.z8',
],
/* [
'80 DAYS',
@ -24,18 +24,18 @@ export default [
[
'Galatea',
'https://ifdb.org/viewgame?id=urxrv27t7qtu52lb',
'https://mirror.ifarchive.org/if-archive/games/zcode/Galatea.zblorb'
'https://mirror.ifarchive.org/if-archive/games/zcode/Galatea.zblorb',
],
[
/* Works. Check inputs */
'Photopia',
'https://ifdb.org/viewgame?id=ju778uv5xaswnlpl',
'https://mirror.ifarchive.org/if-archive/games/zcode/photopia.z5'
'https://mirror.ifarchive.org/if-archive/games/zcode/photopia.z5',
],
[
'Spider and Web',
'https://ifdb.org/viewgame?id=2xyccw3pe0uovfad',
'https://mirror.ifarchive.org/if-archive/games/zcode/Tangle.z5'
'https://mirror.ifarchive.org/if-archive/games/zcode/Tangle.z5',
],
/* [
'Trinity',
@ -60,12 +60,12 @@ export default [
[
'Slouching Towards Bedlam',
'https://ifdb.org/viewgame?id=032krqe6bjn5au78',
'https://mirror.ifarchive.org/if-archive/games/competition2003/zcode/slouch/slouch.z5'
'https://mirror.ifarchive.org/if-archive/games/competition2003/zcode/slouch/slouch.z5',
],
[
'Curses!',
'https://ifdb.org/viewgame?id=plvzam05bmz3enh8',
'https://mirror.ifarchive.org/if-archive/games/zcode/curses.z5'
'https://mirror.ifarchive.org/if-archive/games/zcode/curses.z5',
],
/* [
'howling dogs',
@ -75,12 +75,12 @@ export default [
[
'Violet',
'https://ifdb.org/viewgame?id=4glrrfh7wrp9zz7b',
'https://mirror.ifarchive.org/if-archive/games/zcode/Violet.zblorb'
'https://mirror.ifarchive.org/if-archive/games/zcode/Violet.zblorb',
],
[
'The Wizard Sniffer',
'https://ifdb.org/viewgame?id=uq18rw9gt8j58da',
'https://ifarchive.org/if-archive/games/competition2017/The%20Wizard%20Sniffer/The_Wizard_Sniffer.gblorb'
'https://ifarchive.org/if-archive/games/competition2017/The%20Wizard%20Sniffer/The_Wizard_Sniffer.gblorb',
],
/* [
'Eat Me',
@ -100,12 +100,12 @@ export default [
[
'Shade',
'https://ifdb.org/viewgame?id=hsfc7fnl40k4a30q',
'https://mirror.ifarchive.org/if-archive/games/zcode/shade.z5'
'https://mirror.ifarchive.org/if-archive/games/zcode/shade.z5',
],
[
'Vespers',
'https://ifdb.org/viewgame?id=6dj2vguyiagrhvc2',
'https://mirror.ifarchive.org/if-archive/games/zcode/vespers.z8'
'https://mirror.ifarchive.org/if-archive/games/zcode/vespers.z8',
],
/* [
'Will Not Let Me Go',
@ -135,7 +135,7 @@ export default [
[
'Savoir-Faire',
'https://ifdb.org/viewgame?id=p0cizeb3kiwzlm2p',
'https://mirror.ifarchive.org/if-archive/games/zcode/Savoir-Faire.zblorb'
'https://mirror.ifarchive.org/if-archive/games/zcode/Savoir-Faire.zblorb',
],
/* [
'With Those We Love Alive',
@ -145,7 +145,7 @@ export default [
[
'Aisle',
'https://ifdb.org/viewgame?id=j49crlvd62mhwuzu',
'https://mirror.ifarchive.org/if-archive/games/zcode/Aisle.z5'
'https://mirror.ifarchive.org/if-archive/games/zcode/Aisle.z5',
],
/* [
'Blue Lacuna',
@ -155,7 +155,7 @@ export default [
[
'Gun Mute',
'https://ifdb.org/viewgame?id=xwedbibfksczn7eq',
'https://mirror.ifarchive.org/if-archive/games/tads/GunMute.t3'
'https://mirror.ifarchive.org/if-archive/games/tads/GunMute.t3',
],
/* [
'The King of Shreds and Patches',
@ -180,7 +180,7 @@ export default [
[
'A Beauty Cold and Austere',
'https://ifdb.org/viewgame?id=y9y7jozi0l76bb82',
'https://ifarchive.org/if-archive/games/competition2017/A%20Beauty%20Cold%20and%20Austere/A_Beauty_Cold_and_Austere.gblorb'
'https://ifarchive.org/if-archive/games/competition2017/A%20Beauty%20Cold%20and%20Austere/A_Beauty_Cold_and_Austere.gblorb',
],
/* [
'Cactus Blue Motel',
@ -190,7 +190,7 @@ export default [
[
'Coloratura',
'https://ifdb.org/viewgame?id=g0fl99ovcrq2sqzk',
'https://mirror.ifarchive.org/if-archive/games/competition2013/glulx/coloratura/Coloratura.gblorb'
'https://mirror.ifarchive.org/if-archive/games/competition2013/glulx/coloratura/Coloratura.gblorb',
],
/* [
'Harmonia',
@ -200,12 +200,12 @@ export default [
[
'Lime Ergot',
'https://ifdb.org/viewgame?id=b8mb4fcwmf1hrxl',
'https://mirror.ifarchive.org/if-archive/games/glulx/Lime_Ergot.gblorb'
'https://mirror.ifarchive.org/if-archive/games/glulx/Lime_Ergot.gblorb',
],
[
'Rameses',
'https://ifdb.org/viewgame?id=0stz0hr7a98bp9mp',
'https://mirror.ifarchive.org/if-archive/games/zcode/rameses.zblorb'
'https://mirror.ifarchive.org/if-archive/games/zcode/rameses.zblorb',
],
/* [
'Spellbreaker',
@ -220,7 +220,7 @@ export default [
[
'The Wand',
'https://ifdb.org/viewgame?id=2jil5vbxmbv8riv1',
'https://ifarchive.org/if-archive/games/glulx/Wand.ulx'
'https://ifarchive.org/if-archive/games/glulx/Wand.ulx',
],
/* [
'Zork I',
@ -230,17 +230,17 @@ export default [
[
'1893: A World\'s Fair Mystery',
'https://ifdb.org/viewgame?id=00e0t7swrris5pg6',
'https://mirror.ifarchive.org/if-archive/games/tads/1893.gam'
'https://mirror.ifarchive.org/if-archive/games/tads/1893.gam',
],
[
'Adventure',
'https://ifdb.org/viewgame?id=fft6pu91j85y4acv',
'https://mirror.ifarchive.org/if-archive/games/zcode/Advent.z5'
'https://mirror.ifarchive.org/if-archive/games/zcode/Advent.z5',
],
[
'Alias \'The Magpie\'',
'https://ifdb.org/viewgame?id=yspn49v69hzc8rtb',
'https://ifarchive.org/if-archive/games/competition2018/Alias%20The%20Magpie/Alias%20%27The%20Magpie%27.gblorb'
'https://ifarchive.org/if-archive/games/competition2018/Alias%20The%20Magpie/Alias%20%27The%20Magpie%27.gblorb',
],
/* [
'De Baron',
@ -255,22 +255,22 @@ export default [
[
'Cragne Manor',
'https://ifdb.org/viewgame?id=4x7nltu8p851tn4x',
'https://mirror.ifarchive.org/if-archive/games/glulx/cragne.gblorb'
'https://mirror.ifarchive.org/if-archive/games/glulx/cragne.gblorb',
],
[
'The Edifice',
'https://ifdb.org/viewgame?id=4tb9soabrb4apqzd',
'https://mirror.ifarchive.org/if-archive/games/zcode/edifice.z5'
'https://mirror.ifarchive.org/if-archive/games/zcode/edifice.z5',
],
[
'Endless, Nameless',
'https://ifdb.org/viewgame?id=7vtm1rq16hh3xch',
'https://ifarchive.org/if-archive/games/zcode/nameless.z8'
'https://ifarchive.org/if-archive/games/zcode/nameless.z8',
],
[
'Everybody Dies',
'https://ifdb.org/viewgame?id=lyblvftb8xtlo0a1',
'https://mirror.ifarchive.org/if-archive/games/competition2008/glulx/everybodydies/EverybodyDies.gblorb'
'https://mirror.ifarchive.org/if-archive/games/competition2008/glulx/everybodydies/EverybodyDies.gblorb',
],
/* [
'Fallen London',
@ -280,12 +280,12 @@ export default [
[
'Foo Foo',
'https://ifdb.org/viewgame?id=ec6x9y8qcmsrxob9',
'https://ifarchive.org/if-archive/games/springthing/2016/FooFoo.gblorb'
'https://ifarchive.org/if-archive/games/springthing/2016/FooFoo.gblorb',
],
[
'The Gostak',
'https://ifdb.org/viewgame?id=w5s3sv43s3p98v45',
'https://mirror.ifarchive.org/if-archive/games/zcode/gostak.z5'
'https://mirror.ifarchive.org/if-archive/games/zcode/gostak.z5',
],
/* [
'The Hitchhiker\'s Guide to the Galaxy',
@ -305,27 +305,27 @@ export default [
[
'Inside the Facility',
'https://ifdb.org/viewgame?id=stsdri5zh7a4i5my',
'https://ifarchive.org/if-archive/games/competition2016/Inside%20the%20Facility/Facility.z8'
'https://ifarchive.org/if-archive/games/competition2016/Inside%20the%20Facility/Facility.z8',
],
[
/* [
'Junior Arithmancer',
'https://ifdb.org/viewgame?id=pw1rbjt1t4n4n87s',
'https://ifarchive.org/if-archive/games/competition2018/Junior%20Arithmancer/Junior_Arithmancer.gblorb'
],
'https://ifarchive.org/if-archive/games/competition2018/Junior%20Arithmancer/Junior_Arithmancer.gblorb',
], */
[
'Make It Good',
'https://ifdb.org/viewgame?id=jdrbw1htq4ah8q57',
'https://mirror.ifarchive.org/if-archive/games/zcode/MakeItGood.zblorb'
'https://mirror.ifarchive.org/if-archive/games/zcode/MakeItGood.z8',
],
[
'Sub Rosa',
'https://ifdb.org/viewgame?id=73nvz9yui87ub3sd',
'https://mirror.ifarchive.org/if-archive/games/glulx/Sub_Rosa.gblorb'
'https://mirror.ifarchive.org/if-archive/games/glulx/Sub_Rosa.gblorb',
],
[
'Suveh Nux',
'https://ifdb.org/viewgame?id=xkai23ry99qdxce3',
'https://mirror.ifarchive.org/if-archive/games/zcode/suvehnux.z5'
'https://mirror.ifarchive.org/if-archive/games/zcode/suvehnux.z5',
],
/* [
'their angelical understanding',
@ -340,6 +340,6 @@ export default [
[
'Varicella',
'https://ifdb.org/viewgame?id=ywwlr3tpxnktjasd',
'https://mirror.ifarchive.org/if-archive/games/zcode/vgame.z8'
]
'https://mirror.ifarchive.org/if-archive/games/zcode/vgame.z8',
],
].map(([name, ifdb, url]) => ({ name, ifdb, url }))

View file

@ -1,38 +0,0 @@
.app > .view.home {
padding: var(--inner-padding);
}
.view.home input,
.view.home select {
box-sizing: border-box;
padding: 4px 8px;
color: var(--main-color);
background-color: var(--bg-color);
border: 2px solid var(--main-color);
outline-offset: 0;
font: inherit;
}
.view.home select {
cursor: pointer;
appearance: none;
width: 200px;
}
.view.home label {
display: inline-block;
width: 100%;
max-width: 400px;
}
.view.home label input {
width: 100%;
}
.view.home input[type='file'] {
position: relative;
font-size: 0;
text-indent: -100%;
cursor: pointer;
}

View file

@ -1,10 +1,9 @@
import { h } from 'preact'
import { Link } from 'wouter-preact'
import {
useHashLocation,
buildPlayLinkHref
} from '~/src/utils/utils.routing'
buildPlayLinkHref,
} from '~/src/routing'
import LocalFileSelector from
'~/src/components/FileSelector/LocalFileSelector'
@ -13,15 +12,13 @@ import TargetURLSelector from
import ThemeSelector from
'~/src/components/ThemeSelector/ThemeSelector'
import './HomeView.css'
export default function ({ themeEngine }) {
export default function HomeView ({ themeEngine }) {
const setLocation = useHashLocation()[1]
return (
<main className='view home'>
<main>
<h1>
ifplayer
ElseIFPlayer
</h1>
<section>
@ -31,8 +28,8 @@ export default function ({ themeEngine }) {
<br />
Source code can be found in this <a
target='_blank'
rel='noopener'
href='https://github.com/He4eT/ifplayer'>
rel='noopener noreferrer'
href='https://github.com/He4eT/elseifplayer'>
repository
</a>.
</p>
@ -42,13 +39,18 @@ export default function ({ themeEngine }) {
<section>
<h2>
Interface theme
Interface Theme
</h2>
<ThemeSelector {...{
themeEngine
themeEngine,
}} />
<p>
Preview and choose from available themes on the <Link href={'/#/themes/'}>
themes page
</Link>.
</p>
<p>
<small>
Double-click the input field during the game
@ -61,7 +63,7 @@ export default function ({ themeEngine }) {
<section>
<h2>
Play a game from the list
Play a Game from the List
</h2>
<p>
@ -75,18 +77,19 @@ export default function ({ themeEngine }) {
<section>
<h2>
Play the game from a file
Play the Game from a File
</h2>
<p>
<details>
<summary>Supported formats</summary>
<p>Text-only games are supported:</p>
<ul>
<li>TADS games (.t3, .gam);</li>
<li>Z-machine games (.z3, .z4, .z5, .z8, .blorb);</li>
<li>Glulx VM games (.gblorb, .ulx);</li>
<li>Hugo games (.hex);</li>
<li>Text-only games are supported;</li>
<li>ADRIFT 4 (.taf)</li>
<li>Glulx (.gblorb, .ulx)</li>
<li>Hugo (.hex)</li>
<li>TADS 2/3 (.gam, .t3)</li>
<li>Z-code (.z3, .z4, .z5, .z8, .blorb)</li>
</ul>
</details>
</p>
@ -97,7 +100,7 @@ export default function ({ themeEngine }) {
<LocalFileSelector {...{
setLocation,
buildLink: buildPlayLinkHref,
theme: themeEngine.currentTheme
theme: themeEngine.currentTheme,
}} />
</label>
</p>
@ -108,7 +111,7 @@ export default function ({ themeEngine }) {
<TargetURLSelector {...{
setLocation,
buildLink: buildPlayLinkHref,
theme: themeEngine.currentTheme
theme: themeEngine.currentTheme,
}} />
</label>
</p>

View file

@ -1,26 +0,0 @@
import { h } from 'preact'
import { Link } from 'wouter-preact'
export default () => (
<main>
<div class='status'>
<h1>
404
</h1>
<p>
Page not found
</p>
<hr />
<Link href='/'>
Home
</Link>
|
<a
target='_blank'
rel='noopener'
href='https://github.com/He4eT/ifplayer/issues'>
Report bug
</a>
</div>
</main>
)

View file

@ -0,0 +1,10 @@
import Status from '~/src/components/Player/Status/Status'
export default function NotFoundView () {
return <main>
<Status
stage='fail'
details={['404', 'Page Not Found']}
/>
</main>
}

View file

@ -1,10 +0,0 @@
.app.play {
height: 100%;
}
@media (min-width: 800px) {
.app.play main {
max-height: 90%;
margin: auto;
}
}

View file

@ -1,14 +1,16 @@
import { h } from 'preact'
import { useState, useEffect } from 'preact/hooks'
import UrlPlayer from '~/src/components/Player/UrlPlayer'
import MenuOverlay from '~/src/components/Player/MenuOverlay/MenuOverlay'
import './PlayerView.css'
const decode = (encodedUrl) => decodeURIComponent(encodedUrl)
const decode = encodedUrl => decodeURIComponent(encodedUrl)
export default function ({ setTheme, theme, encodedUrl }) {
useEffect(() => setTheme(theme), [theme])
export default function PlayerView ({
theme, themeEngine, encodedUrl, singleWindow,
}) {
useEffect(() => {
themeEngine.setTheme(theme)
}, [theme, themeEngine])
const [targetUrl, setTargetUrl] = useState(decode(encodedUrl))
@ -16,9 +18,26 @@ export default function ({ setTheme, theme, encodedUrl }) {
setTargetUrl(decode(encodedUrl))
}, [encodedUrl])
const [menuOpen, setMenuOpen] = useState(false)
const onFullscreenRequest = () => {
document.documentElement.requestFullscreen()
}
return (
<main>
<UrlPlayer url={targetUrl} />
<MenuOverlay {...{
themeEngine,
onFullscreenRequest,
menuOpen,
setMenuOpen,
}} />
<UrlPlayer {...{
url: targetUrl,
onFullscreenRequest,
setMenuOpen,
singleWindow,
}} />
</main>
)
}

View file

@ -0,0 +1,73 @@
import { Link } from 'wouter-preact'
import * as s from './ThemesView.module.scss'
const Preview = (themeEngine, theme) =>
<section key={theme} className={[s.themePreview, theme].join(' ')}>
<div className={s.output}>
<div className={[s.message, s.input].join(' ')}>
&gt; look
</div>
<div><br /></div>
<div className={[s.message, s.subheader].join(' ')}>
{theme}
</div>
<div>
Observe a vibrant demonstration of colors at work,
showcasing their versatile usage right before your eyes.
</div>
<div><br /></div>
</div>
<button onClick={() => themeEngine.setTheme(theme)}>
Apply this colors
</button>
</section>
export default function ThemesView ({ themeEngine }) {
const themes = themeEngine
.themes
.map((theme) => Preview(themeEngine, theme))
return (
<main className={s.themes}>
<h1>
Themes Page
</h1>
<p>
Choose one or <Link href='/'>
go back</Link>.
</p>
<section className={[s.themePreview, s.current].join(' ')}>
<h2>
Current Theme
</h2>
<div className={s.output}>
<div className={[s.message, s.input].join(' ')}>
&gt; look
</div>
<div><br /></div>
<div className={[s.message, s.subheader].join(' ')}>
Selected: {themeEngine.currentTheme}
</div>
<div>
You can set random one with the button below
or choose any theme from the list.
</div>
<div><br /></div>
</div>
<button onClick={() => themeEngine.setRandomTheme()}>
Set a random theme
</button>
</section>
<h2>
Theme List
</h2>
<section>
{themes}
</section>
</main>
)
}

View file

@ -0,0 +1,38 @@
.themes {
--current-border: var(--main-color);
.themePreview {
border: 2px solid var(--current-border);
padding: calc(2 * var(--inner-padding));
margin-bottom: 32px;
background-color: var(--bg-color);
color: var(--main-color);
&.current {
padding: 0;
border: none;
margin-block: 64px;
}
.output {
border: 2px solid var(--main-color);
padding: var(--inner-padding);
margin-bottom: 8px;
.message.subheader {
font-weight: bold;
color: var(--accent-color);
text-transform: capitalize;
}
.message.input {
color: var(--input-color);
}
}
button {
width: 100%;
}
}
}