Compare commits

...

92 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
58 changed files with 7680 additions and 20140 deletions

View file

@ -1,19 +1,46 @@
module.exports = { module.exports = {
extends: [ 'env': {
'preact', 'browser': true,
'standard' 'es2021': true
],
overrides: [
{
files: ['*.jsx', '*.js']
}
],
rules: {
'react/display-name': 'off'
}, },
settings: { 'extends': [
react: { 'eslint:recommended',
version: 'latest' 'preact',
} ],
'overrides': [
{
files: ['*.js', '*.jsx'],
},
],
'parserOptions': {
'ecmaVersion': 'latest',
'sourceType': 'module'
},
'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/ node_modules/
dist/ 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,24 +1,65 @@
# ElseIFPlayer # ElseIFPlayer
Interactive Fiction player for the web. ElseIFPlayer is an interactive fiction player for the web.
Powered by [cheap-glkote](https://github.com/He4eT/cheap-glkote) and [Emglken](https://github.com/curiousdannii/emglken). 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/elseifplayer/](https://he4et.github.io/elseifplayer/). 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 ## 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]/[mode]/` - `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.
- `encodedURL` — storyfile location encoded with `encodeURIComponent`. ### CORS
- `theme` — [UI theme](https://github.com/He4eT/elseifplayer/blob/master/src/themes/themes.js), optional.
- `mode` — player interface mode, optional:
- the default mode is used if the option is not set.
- `focus` — single window mode without additional windows, such as the status bar.
### Examples If the player and your storyfile are located on different domains,
- [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/); you need to use appropriate [CORS](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS) settings.
- [Play "Lost Pig" without statusbar with default or last used theme](https://he4et.github.io/elseifplayer/#/play/https%3A%2F%2Fmirror.ifarchive.org%2Fif-archive%2Fgames%2Fzcode%2FLostPig.z8/focus/);
- [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/); If you cannot modify the server settings, you can use the [Parchment Proxy](https://iplayif.com/proxy/) as an alternative.
- [Play "Lost Pig" without statusbar with Dim theme](https://he4et.github.io/elseifplayer/#/play/https%3A%2F%2Fmirror.ifarchive.org%2Fif-archive%2Fgames%2Fzcode%2FLostPig.z8/dim/focus/);
### 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

@ -4,28 +4,18 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta <meta
name="viewport" name="viewport"
content="width=device-width, initial-scale=1.0"> content="width=device-width, initial-scale=1.0, interactive-widget=resizes-content">
<title> <title>
ElseIFPlayer ElseIFPlayer
</title> </title>
<meta <meta
name="description" name="description"
content="Interactive Fiction player for the web."> content="Interactive Fiction player for the web">
</head> </head>
<body> <body>
<div id="root"> <div id="root"></div>
<div class="app play"> <script type="module" src="./src/index.js"></script>
<main>
<div class="status loading">
<div>Loading</div>
</div>
</main>
</div>
</div>
<script src="./src/index.js"></script>
<!-- <goatcounter> --> <!-- <goatcounter> -->
<script> <script>

22885
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,32 +1,44 @@
{ {
"name": "elseifplayer", "name": "elseifplayer",
"version": "0.1.0", "version": "0.2.0",
"description": "Play interactive fiction games in your browser", "description": "Play interactive fiction games in your browser",
"main": "index.js",
"scripts": { "scripts": {
"dev": "parcel index.html", "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" "lint": "eslint --fix src"
}, },
"author": "He4eT", "author": "He4eT",
"license": "MIT", "license": "MIT",
"browserslist": "defaults",
"engines": {
"node": ">=14.0.0"
},
"alias": {
"preact/jsx-dev-runtime": "preact/jsx-runtime"
},
"devDependencies": { "devDependencies": {
"eslint": "^7.31.0", "@parcel/transformer-sass": "^2.9.3",
"eslint-config-preact": "^1.1.4", "buffer": "^6.0.3",
"eslint-config-standard": "^16.0.2", "crypto-browserify": "^3.12.0",
"parcel-bundler": "^1.12.4", "eslint": "^8.44.0",
"parcel-plugin-static-files-copy": "^2.5.1" "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": { "dependencies": {
"@fontsource/open-sans": "^4.2.1", "@fontsource/open-sans": "^5.0.3",
"cheap-glkote": "^0.4.0", "base32768": "^3.0.1",
"emglken": "^0.3.3", "cheap-glkote": "^0.5.1",
"lz-string": "^1.4.4", "emglken": "^0.5.2",
"preact": "^10.5.12", "preact": "^10.15.1",
"wouter-preact": "^2.7.3" "wouter-preact": "^2.7.3"
}, },
"staticFiles": { "staticFiles": {
"staticPath": "node_modules/emglken/build", "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 LocalFileSelector ({ theme, setLocation, buildLink }) {
export default function ({ theme, setLocation, buildLink }) {
const fileInputHandler = ({ target }) => { const fileInputHandler = ({ target }) => {
const file = target.files[0] const file = target.files[0]
const url = `${URL.createObjectURL(file)}#${file.name}` const url = `${URL.createObjectURL(file)}#${file.name}`

View file

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

View file

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

View file

@ -1,62 +0,0 @@
import { h } from 'preact'
import { useEffect, useState } from 'preact/hooks'
import TextMessage from './TextMessage'
export default function ({ 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))
setPrevMessages(rawMessages)
const rawMessagesContent =
rawMessages
.map(x => x.content)
.map(([x]) => x)
.map(({ text }) => text)
.map(text => text.trim())
const isEmpty =
rawMessagesContent
.map(text => text.length)
.every(l => l === 0)
const messages =
rawMessagesContent
.map(text =>
text.replace(' ', ' / '))
.map(text => ({
style: 'grid',
text
}))
setMessages(isEmpty ? [] : messages)
}, [inbox, currentWindow, prevMessages])
return (
<section
className='buffer gridBuffer'>
{messages.map(TextMessage)}
</section>
)
}

View file

@ -1,6 +1,9 @@
import { h } from 'preact'
import { useEffect, useRef, useState } from 'preact/hooks' import { useEffect, useRef, useState } from 'preact/hooks'
import MenuButton from './MenuButton/MenuButton'
import * as s from './InputBox.module.scss'
/* eslint-disable */ /* eslint-disable */
const keyCodes = { const keyCodes = {
KEY_BACKSPACE: 8, KEY_BACKSPACE: 8,
@ -33,11 +36,24 @@ const keyNames = {
} }
/* eslint-enable */ /* eslint-enable */
export default function ({ const hasModifier = (event) => {
const modifiers = [
event.altKey,
event.ctrlKey,
event.metaKey,
event.shiftKey,
]
return modifiers.some((modifier) => modifier === true)
}
export default function InputBox ({
inputType, inputType,
windows, windows,
currentWindowId, currentWindowId,
sendMessage sendMessage,
onFullscreenRequest,
setMenuOpen,
}) { }) {
const [targetWindow, setTargetWindow] = useState(null) const [targetWindow, setTargetWindow] = useState(null)
const [inputText, setInputText] = useState('') const [inputText, setInputText] = useState('')
@ -45,8 +61,15 @@ export default function ({
const inputEl = useRef(null) const inputEl = useRef(null)
useEffect(() => { useEffect(() => {
let setFocus = () => {
inputEl.current && inputEl.current.focus()
}
setInputText('') setInputText('')
inputEl.current && inputEl.current.focus() setFocus()
document.addEventListener('fullscreenchange', setFocus)
return () => document.removeEventListener('fullscreenchange', setFocus)
}, [inputType]) }, [inputType])
useEffect(() => { useEffect(() => {
@ -56,7 +79,7 @@ export default function ({
id === currentWindowId)) id === currentWindowId))
}, [currentWindowId, windows]) }, [currentWindowId, windows])
const send = message => { const send = (message) => {
sendMessage( sendMessage(
message, message,
inputType, inputType,
@ -65,12 +88,14 @@ export default function ({
setInputText('') setInputText('')
} }
const charHandler = event => const charHandler = (event) =>
(event.keyCode === 229 (event.keyCode === 229
? charHandlerMobile ? charHandlerMobile
: charHandlerDefault)(event) : charHandlerDefault)(event)
const charHandlerDefault = event => { const charHandlerDefault = (event) => {
if (hasModifier(event)) { return undefined }
event.preventDefault() event.preventDefault()
const key = const key =
@ -80,8 +105,8 @@ export default function ({
send(key) send(key)
} }
const charHandlerMobile = event => const charHandlerMobile = (event) =>
setTimeout(_ => { setTimeout(() => {
send(event.target.value.slice(-1).toUpperCase()) send(event.target.value.slice(-1).toUpperCase())
setInputText('') setInputText('')
}) })
@ -96,7 +121,7 @@ export default function ({
if (keyCode === keyCodes.KEY_UP) { if (keyCode === keyCodes.KEY_UP) {
setInputText(lastInput) setInputText(lastInput)
setTimeout(_ => { setTimeout(() => {
const end = lastInput.length const end = lastInput.length
inputEl.current.setSelectionRange(end, end) inputEl.current.setSelectionRange(end, end)
}, 0) }, 0)
@ -109,31 +134,36 @@ export default function ({
const inputHandlers = { const inputHandlers = {
char: { char: {
maxlength: '1', maxlength: '1',
autocapitalize: 'off',
autocorrect: 'off',
spellcheck: 'false',
placeholder: 'Press any key here', placeholder: 'Press any key here',
onKeyDown: charHandler onKeyDown: charHandler,
}, },
line: { line: {
placeholder: ' > ', placeholder: ' > ',
onKeyDown: lineArrowHandler, onKeyDown: lineArrowHandler,
onKeyPress: lineHandler onKeyPress: lineHandler,
} },
finished: {
placeholder: 'The program has finished',
disabled: true,
},
} }
const enterFullscreen = _ =>
document.documentElement.requestFullscreen()
return ( return (
<input {...inputHandlers[inputType]} <section className={s.inputControls}>
className='inputBox' <input {...inputHandlers[inputType]}
ref={inputEl} className={s.inputBox}
value={inputText} ref={inputEl}
autofocus value={inputText}
autocomplete='off' autofocus
onDblClick={enterFullscreen} autocomplete='off'
onInput={({ target: { value } }) => setInputText(value)} spellCheck='false'
type='search' /> 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

@ -1,12 +1,16 @@
import { h } from 'preact'
import { useEffect, useRef, useState } from 'preact/hooks' import { useEffect, useRef, useState } from 'preact/hooks'
import TextMessage from './TextMessage' import TextMessage from '../TextMessage/TextMessage'
const isFakeStatus = w => import * as s from '../../Player.module.scss'
const eol = { style: 'endOfLine' }
const scrollTarget = { style: 'scrollTarget' }
const isFakeStatus = (w) =>
w.height < 5 w.height < 5
const trimInputPrompt = messages => const trimInputPrompt = (messages) =>
messages.length < 1 messages.length < 1
? messages ? messages
: messages.slice(-1)[0].text === '>' : messages.slice(-1)[0].text === '>'
@ -21,15 +25,13 @@ const parseInbox = (inbox, currentWindow) => {
if (!currentInbox) { if (!currentInbox) {
return { return {
clear: false, clear: false,
incoming: [] incoming: [scrollTarget],
} }
} }
const { text: inboxMessagesRaw } = const { text: inboxMessagesRaw } =
currentInbox currentInbox
const eol = { style: 'endOfLine' }
const incoming = const incoming =
inboxMessagesRaw inboxMessagesRaw
/* Normalize. */ /* Normalize. */
@ -39,17 +41,17 @@ const parseInbox = (inbox, currentWindow) => {
: [eol]) : [eol])
/* Flatten. */ /* Flatten. */
.reduce((acc, x) => .reduce((acc, x) =>
acc.concat(x), []) acc.concat(x), [scrollTarget])
return { return {
incoming, incoming,
clear: isFakeStatus(currentWindow) clear: isFakeStatus(currentWindow)
? true ? true
: currentInbox.clear : currentInbox.clear,
} }
} }
export default function ({ inbox, currentWindow }) { export default function TextBuffer ({ inbox, currentWindow }) {
const [messages, setMessages] = useState([]) const [messages, setMessages] = useState([])
const textBufferEl = useRef(null) const textBufferEl = useRef(null)
@ -57,35 +59,39 @@ export default function ({ inbox, currentWindow }) {
const { incoming, clear } = const { incoming, clear } =
parseInbox(inbox, currentWindow) parseInbox(inbox, currentWindow)
setMessages(messages => clear setMessages((messages) => clear
? incoming ? incoming
: messages.concat(incoming)) : messages.concat(incoming))
setTimeout(() => { setTimeout(() => {
const inputs = const scrollTargets =
textBufferEl.current.querySelectorAll('.message.input') textBufferEl.current.querySelectorAll(`.${scrollTarget.style}`)
const lastInput = const freshScrollTarget =
inputs[inputs.length - 1] scrollTargets[scrollTargets.length - 1]
textBufferEl.current.scrollTop = freshScrollTarget
lastInput ? freshScrollTarget.scrollIntoView()
? lastInput.offsetTop : textBufferEl.current.scrollTo({
: textBufferEl.current.scrollHeight * 2 top: textBufferEl.current.scrollHeight,
behavior: 'smooth',
})
}, 0) }, 0)
}, [currentWindow, inbox]) }, [currentWindow, inbox])
const classes = [ const classes = () => [
s.buffer,
isFakeStatus(currentWindow) isFakeStatus(currentWindow)
? 'gridBuffer' ? s.gridBuffer
: 'textBuffer', : s.textBuffer,
'buffer'].join(' ') ].join(' ')
return ( return (
<section <section
tabindex='0' tabindex='0'
ref={textBufferEl} ref={textBufferEl}
className={classes}> className={classes()}
{messages.map(TextMessage)} >
{messages.map(TextMessage)}
</section> </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,35 +1,46 @@
import { h } from 'preact'
import { useState, useEffect } from 'preact/hooks' import { useState, useEffect } from 'preact/hooks'
import CheapGlkOte from 'cheap-glkote' import CheapGlkOte from 'cheap-glkote'
import TextBuffer from './TextBuffer' import TextBuffer from './OutputBox/TextBuffer/TextBuffer'
import GridBuffer from './GridBuffer' import GridBuffer from './OutputBox/GridBuffer/GridBuffer'
import InputBox from './InputBox' import InputBox from './InputBox/InputBox'
import Status from './Status' import Status from './Status/Status'
import { Handlers } from './playerHandlers' import {
Handlers,
unhandledRejectionHandler,
} from './common/playerHandlers'
import './player.css' import * as s from './Player.module.scss'
const INITIAL_STATUS = { const INITIAL_STATUS = {
stage: 'loading', stage: 'loading',
details: ['Preparing'] details: ['Preparing'],
} }
const runMachine = ({ engine: Engine, file, handlers }) => { const runMachine = ({ engine: Engine, wasmBinary, storyfile, handlers }) => {
const vm = new Engine() const { Dialog, GlkOte, send } = CheapGlkOte(handlers)
const { glkInterface, sendFn } = CheapGlkOte(handlers) const instance = new Engine()
vm.prepare(file, glkInterface) instance.init(storyfile, {
vm.start() Dialog,
GlkOte,
Glk: {},
wasmBinary,
arguments: ['storyfile'],
})
instance.start()
return { sendFn, instance: vm } return { send, instance }
} }
export default function ({ export default function Player ({
vmParts: { file, engine }, singleWindow vmParts: { storyfile, engine, wasmBinary },
onFullscreenRequest,
setMenuOpen,
singleWindow,
}) { }) {
const [status, setStatus] = useState(INITIAL_STATUS) const [status, setStatus] = useState(INITIAL_STATUS)
@ -42,36 +53,49 @@ export default function ({
const [sendMessage, setSendMessage] = useState(null) const [sendMessage, setSendMessage] = useState(null)
useEffect(() => { useEffect(() => {
const vm = runMachine({ const handlers = Handlers({
engine, setStatus,
file, setWindows,
handlers: Handlers({ setCurrentWindowId,
setStatus, setInputType,
setWindows, setInbox,
setCurrentWindowId,
setInputType,
setInbox
})
}) })
setVm(vm) setVm(runMachine({
}, [file, engine]) engine,
wasmBinary,
storyfile,
handlers,
}))
const rejectionHandler =
unhandledRejectionHandler(handlers.onExit)
window.addEventListener('unhandledrejection', rejectionHandler)
return () => {
setVm(null)
window.removeEventListener('unhandledrejection', rejectionHandler)
}
}, [storyfile, engine, wasmBinary])
useEffect(() => { useEffect(() => {
setSendMessage(_ => vm setSendMessage(() => vm
? vm.sendFn ? vm.send
: null) : null)
return () => setSendMessage(null)
}, [vm]) }, [vm])
const textWindow = inbox => currentWindow => { const textWindow = (inbox) => (currentWindow) => {
const props = { const props = {
inbox, inbox,
currentWindow currentWindow,
} }
return ({ return ({
buffer: <TextBuffer {...props} />, buffer: <TextBuffer {...props} />,
grid: <GridBuffer {...props} /> grid: <GridBuffer {...props} />,
})[currentWindow.type] })[currentWindow.type]
} }
@ -80,20 +104,24 @@ export default function ({
return status.stage !== 'ready' return status.stage !== 'ready'
? (<Status {...status} />) ? (<Status {...status} />)
: (<section className='ifplayer'> : (<section className={s.elseifplayer}>
<section className='output'>{ <section className={s.output}>
{
windows windows
.sort(byTop) .sort(byTop)
.filter(singleWindow .filter(singleWindow
? ({ id }) => id === currentWindowId ? ({ id }) => id === currentWindowId
: _ => true) : () => true)
.map(textWindow(inbox))} .map(textWindow(inbox))
}
</section> </section>
<InputBox {...{ <InputBox {...{
inputType, inputType,
windows, windows,
currentWindowId, currentWindowId,
sendMessage sendMessage,
onFullscreenRequest,
setMenuOpen,
}} /> }} />
</section>) </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 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 class='status loading'>
{details.map(x => (<div key={x}>{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,16 +0,0 @@
import { h } from 'preact'
export default function ({ style, text }) {
const defaultContent = (
<span class={['message', style].join(' ')}>
{text}
</span>)
return ({
grid: (<div>{text}&nbsp;</div>),
input: (<span class='message input'>&gt; {text}</span>),
subheader: (<strong>{text}</strong>),
emphasized: (<em>{text}</em>),
endOfLine: (<br />)
})[style] || defaultContent
}

View file

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

View file

@ -2,36 +2,46 @@ import bocfel from 'emglken/src/bocfel.js'
import git from 'emglken/src/git.js' import git from 'emglken/src/git.js'
import hugo from 'emglken/src/hugo.js' import hugo from 'emglken/src/hugo.js'
import tads from 'emglken/src/tads.js' import tads from 'emglken/src/tads.js'
import scare from 'emglken/src/scare.js'
const formats = [ const formats = [
{ {
id: 'bocfel', id: 'bocfel',
extensions: /z([3458]|blorb)$/, extensions: /z([3458]|blorb)$/,
engine: bocfel engine: bocfel,
}, },
{ {
id: 'git', id: 'git',
extensions: /(gblorb|ulx)$/, extensions: /(gblorb|ulx)$/,
engine: git engine: git,
}, },
{ {
id: 'hugo', id: 'hugo',
extensions: /hex$/, extensions: /hex$/,
engine: hugo engine: hugo,
},
{
id: 'scare',
extensions: /taf$/,
engine: scare,
}, },
{ {
id: 'tads', id: 'tads',
extensions: /(gam|t3)$/, extensions: /(gam|t3)$/,
engine: tads engine: tads,
} },
] ]
export const engineByFilename = filename => { export const engineByFilename = (filename) => {
const format = formats.find(x => const format = formats.find((x) =>
x.extensions.test(filename)) x.extensions.test(filename))
if (format) { if (format) {
return format.engine return {
...format,
/* @see staticFiles in package.json */
wasmBinaryName: `emglken/${format.id}-core.wasm`,
}
} }
throw new Error('Unsupported file type') 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,103 +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: 0;
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 .output {
display: flex;
flex-grow: 2;
flex-direction: column;
overflow-y: hidden;
border: var(--border-width) solid var(--main-color);
}
.ifplayer .output .buffer {
overflow-y: scroll;
box-sizing: border-box;
padding: var(--inner-padding);
scrollbar-color: var(--main-color) var(--bg-color);
scrollbar-width: thin;
}
.ifplayer .output .buffer:empty {
display: none;
}
.ifplayer .output .buffer::-webkit-scrollbar {
width: 8px;
}
.ifplayer .output .buffer::-webkit-scrollbar-thumb {
background-color: var(--main-color);
border: 4px solid var(--bg-color);
border-left-width: 0px;
}
.ifplayer .output .gridBuffer {
flex-shrink: 0;
max-height: 100%;
border-bottom: var(--border-width) solid var(--main-color);
}
.ifplayer .output .textBuffer {
flex: 2 1;
outline: none;
}
.ifplayer .output .textBuffer > br:first-child,
.ifplayer .output .textBuffer > br:last-child,
.ifplayer .output .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,51 +0,0 @@
import {
compressToUTF16 as encode,
decompressFromUTF16 as decode
} from 'lz-string'
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, _, 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)
}
})

View file

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

View file

@ -1,71 +1,10 @@
import { h, render } from 'preact' import { 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 '@fontsource/open-sans' import '@fontsource/open-sans'
import '~/src/style/base.css'
function App () { import './style/base.scss'
const themeEngine = useThemeEngine() import './style/controls.scss'
const [location] = useHashLocation()
const playerView = (themeEngine, singleWindow) => params => import App from './App'
(<PlayerView {...{
...themeEngine,
...params,
singleWindow
}} />)
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'>
{ playerView(themeEngine, false) }
</Route>
<Route path='/play/:encodedUrl/focus'>
{ playerView(themeEngine, true) }
</Route>
<Route path='/play/:encodedUrl/:theme/focus'>
{ playerView(themeEngine, true) }
</Route>
<Route path='/play/:encodedUrl/:theme'>
{ playerView(themeEngine, false) }
</Route>
<Route>
<NotFoundView />
</Route>
</Switch>
</div>
</Router>
)
}
render(<App />, document.getElementById('root')) 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,95 +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);
/* Fix for Jumping Scrollbar Issue */
padding-left: calc(100vw - 100%);
}
.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,21 +1,8 @@
import { useState } from 'preact/hooks' import { useState } from 'preact/hooks'
import { themes } from './themeList.js'
import './themes.css' import './themes.css'
const themes = [
'light',
'dim',
'dark',
'solarized-light',
'solarized-dark',
'emo',
'nord',
'redrum',
'toxin',
'valve',
'wasp'
]
const LS_THEME_KEY = 'elseifplayer/theme' const LS_THEME_KEY = 'elseifplayer/theme'
const DEFAULT_THEME = themes[0] const DEFAULT_THEME = themes[0]
@ -24,7 +11,7 @@ const getSavedTheme = () => {
return savedTheme || DEFAULT_THEME return savedTheme || DEFAULT_THEME
} }
const assertTheme = theme => const assertTheme = (theme) =>
themes.includes(theme) themes.includes(theme)
? theme ? theme
: getSavedTheme() : getSavedTheme()
@ -33,12 +20,17 @@ export const useThemeEngine = (initialTheme = getSavedTheme()) => {
const [currentTheme, setCurrentTheme] = const [currentTheme, setCurrentTheme] =
useState(initialTheme) useState(initialTheme)
const setTheme = theme => { const setTheme = (theme) => {
const newTheme = assertTheme(theme) const newTheme = assertTheme(theme)
setCurrentTheme(newTheme) setCurrentTheme(newTheme)
localStorage.setItem(LS_THEME_KEY, 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 { Link } from 'wouter-preact'
import GameEntry from import GameEntry from
@ -6,18 +5,17 @@ import GameEntry from
import top2019 from './top2019' import top2019 from './top2019'
import './GamesView.css' import * as s from './GamesView.module.scss'
const tutorialGame = { const tutorialGame = {
name: 'The Dreamhold', name: 'The Dreamhold',
ifdb: 'https://ifdb.org/viewgame?id=3myqnrs64nbtwdaz', 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 ( return (
<main className='view games'> <main className={s.games}>
<h1> <h1>
<a <a
target='_blank' target='_blank'
@ -25,7 +23,7 @@ export default function () {
href='https://ifdb.org/' href='https://ifdb.org/'
title='The Interactive Fiction Database'> title='The Interactive Fiction Database'>
IFDB IFDB
</a> games </a> Games
</h1> </h1>
<p> <p>
@ -33,25 +31,25 @@ export default function () {
go back</Link>. go back</Link>.
</p> </p>
<h2> <section className={s.tutorial}>
Tutorial <h2>
</h2> Tutorial
</h2>
<p> <p>
If you are not familiar with Interactive Fiction, If you are not familiar with Interactive Fiction,
you should start with this tutorial game you should start with this tutorial game
by&nbsp;Andrew&nbsp;Plotkin: by&nbsp;Andrew&nbsp;Plotkin:
</p> </p>
<ul> <ul>
<li> <li>
<GameEntry {...{ <GameEntry {...{
...tutorialGame ...tutorialGame,
}} /> }} />
</li> </li>
</ul> </ul>
</section>
<br />
<h2> <h2>
Interactive Fiction Top 50 of All Time Interactive Fiction Top 50 of All Time
@ -65,18 +63,18 @@ export default function () {
Every four years </a>, Victor Gijsbers puts Every four years </a>, Victor Gijsbers puts
together a list of the top 50 IF games of all time. 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' target='_blank'
rel='noopener noreferrer' rel='noopener noreferrer'
href='https://ifdb.org/viewcomp?id=1lv599reviaxvwo7'> href='https://ifdb.org/viewcomp?id=1lv599reviaxvwo7'>
list for 2019</a>: list from 2019</a>:
</p> </p>
<ol> <ol>
{top2019.map(game => ( {top2019.map((game) => (
<li key={game[0]}> <li key={game.name}>
<GameEntry {...{ <GameEntry {...{
...game ...game,
}} /> }} />
</li> </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 [ export default [
[
'Lost Pig',
'https://ifdb.org/viewgame?id=mohwfk47yjzii14w',
'https://mirror.ifarchive.org/if-archive/games/zcode/LostPig.z8',
],
[ [
/* Check with cheap-glk */ /* Check with cheap-glk */
'Counterfeit Monkey', 'Counterfeit Monkey',
'https://ifdb.org/viewgame?id=aearuuxv83plclpl', 'https://ifdb.org/viewgame?id=aearuuxv83plclpl',
'https://mirror.ifarchive.org/if-archive/games/glulx/CounterfeitMonkey.gblorb' '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'
], ],
[ [
/* Works. Check inputs */ /* Works. Check inputs */
'Anchorhead', 'Anchorhead',
'https://ifdb.org/viewgame?id=op0uw1gn1tjqmjt7', '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', '80 DAYS',
@ -24,18 +24,18 @@ export default [
[ [
'Galatea', 'Galatea',
'https://ifdb.org/viewgame?id=urxrv27t7qtu52lb', '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 */ /* Works. Check inputs */
'Photopia', 'Photopia',
'https://ifdb.org/viewgame?id=ju778uv5xaswnlpl', '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', 'Spider and Web',
'https://ifdb.org/viewgame?id=2xyccw3pe0uovfad', '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', 'Trinity',
@ -60,12 +60,12 @@ export default [
[ [
'Slouching Towards Bedlam', 'Slouching Towards Bedlam',
'https://ifdb.org/viewgame?id=032krqe6bjn5au78', '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!', 'Curses!',
'https://ifdb.org/viewgame?id=plvzam05bmz3enh8', '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', 'howling dogs',
@ -75,12 +75,12 @@ export default [
[ [
'Violet', 'Violet',
'https://ifdb.org/viewgame?id=4glrrfh7wrp9zz7b', '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', 'The Wizard Sniffer',
'https://ifdb.org/viewgame?id=uq18rw9gt8j58da', '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', 'Eat Me',
@ -100,12 +100,12 @@ export default [
[ [
'Shade', 'Shade',
'https://ifdb.org/viewgame?id=hsfc7fnl40k4a30q', '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', 'Vespers',
'https://ifdb.org/viewgame?id=6dj2vguyiagrhvc2', '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', 'Will Not Let Me Go',
@ -135,7 +135,7 @@ export default [
[ [
'Savoir-Faire', 'Savoir-Faire',
'https://ifdb.org/viewgame?id=p0cizeb3kiwzlm2p', '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', 'With Those We Love Alive',
@ -145,7 +145,7 @@ export default [
[ [
'Aisle', 'Aisle',
'https://ifdb.org/viewgame?id=j49crlvd62mhwuzu', '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', 'Blue Lacuna',
@ -155,7 +155,7 @@ export default [
[ [
'Gun Mute', 'Gun Mute',
'https://ifdb.org/viewgame?id=xwedbibfksczn7eq', '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', 'The King of Shreds and Patches',
@ -180,7 +180,7 @@ export default [
[ [
'A Beauty Cold and Austere', 'A Beauty Cold and Austere',
'https://ifdb.org/viewgame?id=y9y7jozi0l76bb82', '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', 'Cactus Blue Motel',
@ -190,7 +190,7 @@ export default [
[ [
'Coloratura', 'Coloratura',
'https://ifdb.org/viewgame?id=g0fl99ovcrq2sqzk', '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', 'Harmonia',
@ -200,12 +200,12 @@ export default [
[ [
'Lime Ergot', 'Lime Ergot',
'https://ifdb.org/viewgame?id=b8mb4fcwmf1hrxl', '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', 'Rameses',
'https://ifdb.org/viewgame?id=0stz0hr7a98bp9mp', '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', 'Spellbreaker',
@ -220,7 +220,7 @@ export default [
[ [
'The Wand', 'The Wand',
'https://ifdb.org/viewgame?id=2jil5vbxmbv8riv1', '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', 'Zork I',
@ -230,17 +230,17 @@ export default [
[ [
'1893: A World\'s Fair Mystery', '1893: A World\'s Fair Mystery',
'https://ifdb.org/viewgame?id=00e0t7swrris5pg6', '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', 'Adventure',
'https://ifdb.org/viewgame?id=fft6pu91j85y4acv', '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\'', 'Alias \'The Magpie\'',
'https://ifdb.org/viewgame?id=yspn49v69hzc8rtb', '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', 'De Baron',
@ -255,22 +255,22 @@ export default [
[ [
'Cragne Manor', 'Cragne Manor',
'https://ifdb.org/viewgame?id=4x7nltu8p851tn4x', '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', 'The Edifice',
'https://ifdb.org/viewgame?id=4tb9soabrb4apqzd', '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', 'Endless, Nameless',
'https://ifdb.org/viewgame?id=7vtm1rq16hh3xch', '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', 'Everybody Dies',
'https://ifdb.org/viewgame?id=lyblvftb8xtlo0a1', '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', 'Fallen London',
@ -280,12 +280,12 @@ export default [
[ [
'Foo Foo', 'Foo Foo',
'https://ifdb.org/viewgame?id=ec6x9y8qcmsrxob9', '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', 'The Gostak',
'https://ifdb.org/viewgame?id=w5s3sv43s3p98v45', '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', 'The Hitchhiker\'s Guide to the Galaxy',
@ -305,27 +305,27 @@ export default [
[ [
'Inside the Facility', 'Inside the Facility',
'https://ifdb.org/viewgame?id=stsdri5zh7a4i5my', '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', 'Junior Arithmancer',
'https://ifdb.org/viewgame?id=pw1rbjt1t4n4n87s', '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', 'Make It Good',
'https://ifdb.org/viewgame?id=jdrbw1htq4ah8q57', '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', 'Sub Rosa',
'https://ifdb.org/viewgame?id=73nvz9yui87ub3sd', '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', 'Suveh Nux',
'https://ifdb.org/viewgame?id=xkai23ry99qdxce3', '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', 'their angelical understanding',
@ -340,6 +340,6 @@ export default [
[ [
'Varicella', 'Varicella',
'https://ifdb.org/viewgame?id=ywwlr3tpxnktjasd', '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 })) ].map(([name, ifdb, url]) => ({ name, ifdb, url }))

View file

@ -1,40 +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;
-webkit-appearance: none;
border-radius: 0;
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 { Link } from 'wouter-preact'
import { import {
useHashLocation, useHashLocation,
buildPlayLinkHref buildPlayLinkHref,
} from '~/src/utils/utils.routing' } from '~/src/routing'
import LocalFileSelector from import LocalFileSelector from
'~/src/components/FileSelector/LocalFileSelector' '~/src/components/FileSelector/LocalFileSelector'
@ -13,13 +12,11 @@ import TargetURLSelector from
import ThemeSelector from import ThemeSelector from
'~/src/components/ThemeSelector/ThemeSelector' '~/src/components/ThemeSelector/ThemeSelector'
import './HomeView.css' export default function HomeView ({ themeEngine }) {
export default function ({ themeEngine }) {
const setLocation = useHashLocation()[1] const setLocation = useHashLocation()[1]
return ( return (
<main className='view home'> <main>
<h1> <h1>
ElseIFPlayer ElseIFPlayer
</h1> </h1>
@ -42,13 +39,18 @@ export default function ({ themeEngine }) {
<section> <section>
<h2> <h2>
Interface theme Interface Theme
</h2> </h2>
<ThemeSelector {...{ <ThemeSelector {...{
themeEngine themeEngine,
}} /> }} />
<p>
Preview and choose from available themes on the <Link href={'/#/themes/'}>
themes page
</Link>.
</p>
<p> <p>
<small> <small>
Double-click the input field during the game Double-click the input field during the game
@ -61,7 +63,7 @@ export default function ({ themeEngine }) {
<section> <section>
<h2> <h2>
Play a game from the list Play a Game from the List
</h2> </h2>
<p> <p>
@ -75,18 +77,19 @@ export default function ({ themeEngine }) {
<section> <section>
<h2> <h2>
Play the game from a file Play the Game from a File
</h2> </h2>
<p> <p>
<details> <details>
<summary>Supported formats</summary> <summary>Supported formats</summary>
<p>Text-only games are supported:</p>
<ul> <ul>
<li>TADS games (.t3, .gam);</li> <li>ADRIFT 4 (.taf)</li>
<li>Z-machine games (.z3, .z4, .z5, .z8, .blorb);</li> <li>Glulx (.gblorb, .ulx)</li>
<li>Glulx VM games (.gblorb, .ulx);</li> <li>Hugo (.hex)</li>
<li>Hugo games (.hex);</li> <li>TADS 2/3 (.gam, .t3)</li>
<li>Text-only games are supported;</li> <li>Z-code (.z3, .z4, .z5, .z8, .blorb)</li>
</ul> </ul>
</details> </details>
</p> </p>
@ -97,7 +100,7 @@ export default function ({ themeEngine }) {
<LocalFileSelector {...{ <LocalFileSelector {...{
setLocation, setLocation,
buildLink: buildPlayLinkHref, buildLink: buildPlayLinkHref,
theme: themeEngine.currentTheme theme: themeEngine.currentTheme,
}} /> }} />
</label> </label>
</p> </p>
@ -108,7 +111,7 @@ export default function ({ themeEngine }) {
<TargetURLSelector {...{ <TargetURLSelector {...{
setLocation, setLocation,
buildLink: buildPlayLinkHref, buildLink: buildPlayLinkHref,
theme: themeEngine.currentTheme theme: themeEngine.currentTheme,
}} /> }} />
</label> </label>
</p> </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 noreferrer'
href='https://github.com/He4eT/elseifplayer/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,16 +1,16 @@
import { h } from 'preact'
import { useState, useEffect } from 'preact/hooks' import { useState, useEffect } from 'preact/hooks'
import UrlPlayer from '~/src/components/Player/UrlPlayer' 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 PlayerView ({
theme, themeEngine, encodedUrl, singleWindow,
export default function ({
setTheme, theme, encodedUrl, singleWindow
}) { }) {
useEffect(() => setTheme(theme), [setTheme, theme]) useEffect(() => {
themeEngine.setTheme(theme)
}, [theme, themeEngine])
const [targetUrl, setTargetUrl] = useState(decode(encodedUrl)) const [targetUrl, setTargetUrl] = useState(decode(encodedUrl))
@ -18,11 +18,25 @@ export default function ({
setTargetUrl(decode(encodedUrl)) setTargetUrl(decode(encodedUrl))
}, [encodedUrl]) }, [encodedUrl])
const [menuOpen, setMenuOpen] = useState(false)
const onFullscreenRequest = () => {
document.documentElement.requestFullscreen()
}
return ( return (
<main> <main>
<MenuOverlay {...{
themeEngine,
onFullscreenRequest,
menuOpen,
setMenuOpen,
}} />
<UrlPlayer {...{ <UrlPlayer {...{
url: targetUrl, url: targetUrl,
singleWindow onFullscreenRequest,
setMenuOpen,
singleWindow,
}} /> }} />
</main> </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%;
}
}
}