chore: initial
This commit is contained in:
commit
fbeee2bb86
68
.eslintrc.cjs
Normal file
68
.eslintrc.cjs
Normal file
|
@ -0,0 +1,68 @@
|
|||
/**
|
||||
* This is intended to be a basic starting point for linting in your app.
|
||||
* It relies on recommended configs out of the box for simplicity, but you can
|
||||
* and should modify this configuration to best suit your team's needs.
|
||||
*/
|
||||
|
||||
/** @type {import('eslint').Linter.Config} */
|
||||
module.exports = {
|
||||
root: true,
|
||||
parserOptions: {
|
||||
ecmaVersion: "latest",
|
||||
sourceType: "module",
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
},
|
||||
},
|
||||
env: {
|
||||
browser: true,
|
||||
commonjs: true,
|
||||
es6: true,
|
||||
},
|
||||
|
||||
// Base config
|
||||
extends: ["eslint:recommended"],
|
||||
|
||||
overrides: [
|
||||
// React
|
||||
{
|
||||
files: ["**/*.{js,jsx}"],
|
||||
plugins: ["react", "jsx-a11y"],
|
||||
extends: [
|
||||
"plugin:react/recommended",
|
||||
"plugin:react/jsx-runtime",
|
||||
"plugin:react-hooks/recommended",
|
||||
"plugin:jsx-a11y/recommended",
|
||||
],
|
||||
settings: {
|
||||
react: {
|
||||
version: "detect",
|
||||
},
|
||||
formComponents: ["Form"],
|
||||
linkComponents: [
|
||||
{ name: "Link", linkAttribute: "to" },
|
||||
{ name: "NavLink", linkAttribute: "to" },
|
||||
],
|
||||
"import/resolver": {
|
||||
typescript: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// mocha
|
||||
{
|
||||
files: ['**/*.test.js'],
|
||||
globals: {
|
||||
it: true,
|
||||
},
|
||||
},
|
||||
|
||||
// Node
|
||||
{
|
||||
files: [".eslintrc.cjs", "server.js"],
|
||||
env: {
|
||||
node: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
|
@ -0,0 +1,5 @@
|
|||
node_modules
|
||||
|
||||
/.cache
|
||||
/build
|
||||
.env
|
17
.vscode/launch.json
vendored
Normal file
17
.vscode/launch.json
vendored
Normal file
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"name": "Terrible",
|
||||
"skipFiles": [
|
||||
"<node_internals>/**"
|
||||
],
|
||||
"resolveSourceMapLocations": [],
|
||||
"cwd": "${workspaceFolder}",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "dev"],
|
||||
}
|
||||
]
|
||||
}
|
78
README.md
Normal file
78
README.md
Normal file
|
@ -0,0 +1,78 @@
|
|||
# Welcome and Do Terrible!
|
||||
|
||||
Do Terrible is a **Commentary Game**. You might have heard of another similar game called Cards
|
||||
Against Humanity. I want to make it **absolutely clear** that I have nothing to do with them.
|
||||
|
||||
An instance of the game is hosted at https://doterrible.com. Have fun!
|
||||
|
||||
## JS not required (and other marvels)
|
||||
|
||||
The game is perfectly playable including real-time game state updates and chat (with autoscroll
|
||||
and user name/presence updates) even when JavaScript is disabled.
|
||||
|
||||
I found the [(Ab)use the Platform](https://github.com/jenseng/abuse-the-platform) project very
|
||||
inspirational and I've always fancied the idea of doing things without requiring script. This
|
||||
project is a successful experiment in delivering a polished real-time gaming experience without requiring
|
||||
a client to execute any script whatsoever.
|
||||
|
||||
A list of interesting technologies and techniques used in this project:
|
||||
|
||||
- [Remix](https://remix.run)
|
||||
- [Vite](https://vitejs.dev/)
|
||||
- [Forms](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/form)
|
||||
- [Progressive enhancement](https://developer.mozilla.org/en-US/docs/Glossary/Progressive_Enhancement)
|
||||
- [Response](https://developer.mozilla.org/en-US/docs/Web/API/Response), [Server-sent events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events), and [long polling](https://javascript.info/long-polling) including at the [top level](./app/utils/top-level-long-poll.js) to transform the
|
||||
[React pipeable stream](https://react.dev/reference/react-dom/server/renderToPipeableStream)
|
||||
output
|
||||
- [Algorithm L reservoir sampling](https://en.wikipedia.org/wiki/Reservoir_sampling#Optimal:_Algorithm_L)
|
||||
to efficiently generate custom decks for each game from a large pool of packs
|
||||
- Dynamic text scaling (when scripts are enabled)
|
||||
|
||||
## Screenshots
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
## Running locally
|
||||
|
||||
### Install
|
||||
|
||||
Install the dependencies with `npm`:
|
||||
|
||||
```sh
|
||||
npm install
|
||||
```
|
||||
|
||||
### Get some packs
|
||||
|
||||
You must install some packs before you can play. Check out
|
||||
https://github.com/crhallberg/json-against-humanity and copy e.g. `cah-all-full.json` to the
|
||||
`data/packs` directory.
|
||||
|
||||
**NOTE**: The grammatical quality of many of those packs (even/especially the Official™ ones)
|
||||
leaves a great deal to be desired.
|
||||
|
||||
### Play
|
||||
|
||||
Finally, you may:
|
||||
|
||||
```sh
|
||||
npm run dev
|
||||
```
|
||||
|
||||
to run a dev server.
|
||||
|
||||
## Production build
|
||||
|
||||
```sh
|
||||
npm run build
|
||||
```
|
||||
|
||||
After that, you're on your own, hero. :slightly_smiling_face:
|
||||
|
||||
## Hey, you screwed up [whatever]
|
||||
|
||||
Probably! Feel free to create issues and tell me what I screwed up. :) The no-js acrobatics are
|
||||
probably terrible for accessibility. I'd love to know how to improve!
|
116
app/answers/index.jsx
Normal file
116
app/answers/index.jsx
Normal file
|
@ -0,0 +1,116 @@
|
|||
import {useFetcher} from '@remix-run/react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import FluidText from '#fluid-text';
|
||||
import {useIsHydrated, useSelection} from '#hooks';
|
||||
|
||||
import styles from './index.module.css';
|
||||
|
||||
function Answers({
|
||||
choices,
|
||||
className,
|
||||
count,
|
||||
disabled,
|
||||
}) {
|
||||
const fetcher = useFetcher({key: 'answers'});
|
||||
const isHydrated = useIsHydrated();
|
||||
const [selection, setSelection] = useSelection();
|
||||
return (
|
||||
<fetcher.Form
|
||||
method={count === selection.length ? 'post' : 'get'}
|
||||
className={styles.answers}
|
||||
>
|
||||
<input name="action" value="answer" type="hidden" />
|
||||
{selection.length > 0 && (
|
||||
selection.map((answer) => (
|
||||
<input key={answer} name="selection" type="hidden" value={answer} />
|
||||
))
|
||||
)}
|
||||
<div className={styles.buttons}>
|
||||
{
|
||||
count === selection.length
|
||||
? (
|
||||
<div className={styles.submit}>
|
||||
<button name="confirm" type="submit">
|
||||
<div className={styles.inside}>
|
||||
<FluidText>
|
||||
Send it
|
||||
</FluidText>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
name="cancel"
|
||||
onClick={() => {
|
||||
setSelection([]);
|
||||
}}
|
||||
type="submit"
|
||||
>
|
||||
<div className={styles.inside}>
|
||||
<FluidText>
|
||||
Nevermind
|
||||
</FluidText>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
choices.map((choice, i) => (
|
||||
<button
|
||||
className={[
|
||||
className,
|
||||
styles.answer,
|
||||
selection.includes(i) && styles.selected,
|
||||
].filter(Boolean).join(' ')}
|
||||
disabled={disabled || (!isHydrated && selection.includes(i))}
|
||||
key={i}
|
||||
name="selection"
|
||||
value={i}
|
||||
onClick={(event) => {
|
||||
if (selection.includes(i)) {
|
||||
selection.splice(selection.indexOf(i), 1);
|
||||
setSelection([...selection]);
|
||||
}
|
||||
else {
|
||||
setSelection([...selection, i]);
|
||||
}
|
||||
event.preventDefault();
|
||||
}}
|
||||
type="submit"
|
||||
>
|
||||
<div className={styles.top} />
|
||||
<div className={styles.left} />
|
||||
<div className={styles.text}>
|
||||
<FluidText>
|
||||
<div className={styles.choice}>
|
||||
{choice}
|
||||
</div>
|
||||
</FluidText>
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</fetcher.Form>
|
||||
);
|
||||
}
|
||||
|
||||
Answers.defaultProps = {
|
||||
choices: [],
|
||||
className: '',
|
||||
disabled: false,
|
||||
};
|
||||
|
||||
Answers.propTypes = {
|
||||
choices: PropTypes.arrayOf(PropTypes.oneOfType([
|
||||
PropTypes.string,
|
||||
PropTypes.node,
|
||||
])),
|
||||
count: PropTypes.number.isRequired,
|
||||
className: PropTypes.string,
|
||||
disabled: PropTypes.bool,
|
||||
};
|
||||
|
||||
Answers.displayName = 'Answers';
|
||||
|
||||
export default Answers;
|
171
app/answers/index.module.css
Normal file
171
app/answers/index.module.css
Normal file
|
@ -0,0 +1,171 @@
|
|||
.answers {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
align-content: flex-start;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.answer {
|
||||
align-items: center;
|
||||
background: #dfdfdf;
|
||||
border: none;
|
||||
height: calc(33.333% - 2vh);
|
||||
margin: 1vh;
|
||||
padding: 0;
|
||||
width: calc(33.333% - 2vh);
|
||||
&:nth-child(1):nth-last-child(2),
|
||||
&:nth-child(2):nth-last-child(1) {
|
||||
height: calc(50% - 2vh);
|
||||
width: calc(100% - 2vh);
|
||||
}
|
||||
&:nth-child(1):nth-last-child(3),
|
||||
&:nth-child(2):nth-last-child(2),
|
||||
&:nth-child(3):nth-last-child(1) {
|
||||
width: calc(100% - 2vh);
|
||||
}
|
||||
&:nth-child(1):nth-last-child(4),
|
||||
&:nth-child(2):nth-last-child(3),
|
||||
&:nth-child(3):nth-last-child(2),
|
||||
&:nth-child(4):nth-last-child(1) {
|
||||
height: calc(50% - 2vh);
|
||||
width: calc(50% - 2vh);
|
||||
}
|
||||
&:nth-child(1):nth-last-child(5),
|
||||
&:nth-child(2):nth-last-child(4),
|
||||
&:nth-child(3):nth-last-child(3),
|
||||
&:nth-child(4):nth-last-child(2),
|
||||
&:nth-child(5):nth-last-child(1) {
|
||||
width: calc(50% - 2vh);
|
||||
}
|
||||
&:nth-child(1):nth-last-child(6),
|
||||
&:nth-child(2):nth-last-child(5),
|
||||
&:nth-child(3):nth-last-child(4),
|
||||
&:nth-child(4):nth-last-child(3),
|
||||
&:nth-child(5):nth-last-child(2),
|
||||
&:nth-child(6):nth-last-child(1) {
|
||||
width: calc(50% - 2vh);
|
||||
}
|
||||
&:disabled {
|
||||
color: #070707;
|
||||
}
|
||||
&:focus {
|
||||
box-shadow: none;
|
||||
}
|
||||
&.selected .text {
|
||||
background-color: #f7f7f7;
|
||||
&:hover {
|
||||
background-color: #fff;
|
||||
};
|
||||
}
|
||||
&:not(:disabled) .text:hover {
|
||||
background:
|
||||
linear-gradient(to top left, transparent 90%, #eee),
|
||||
linear-gradient(to bottom right, transparent 30%, #fff 60%, transparent 90%)
|
||||
;
|
||||
background-color: #f4f4f4;
|
||||
color: #070707;
|
||||
cursor: pointer;
|
||||
};
|
||||
&.selected .text {
|
||||
border: 1px solid #777;
|
||||
|
||||
.left, .top {
|
||||
background: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.separator {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.text {
|
||||
background:
|
||||
linear-gradient(to top left, transparent 90%, #ccc),
|
||||
linear-gradient(to bottom right, transparent 30%, #e9e9e9 60%, transparent 90%)
|
||||
;
|
||||
border: 1px solid transparent;
|
||||
box-shadow: 1px 1px 1px #bbb;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.choice {
|
||||
padding: 2vh 3.5vw;
|
||||
}
|
||||
|
||||
.left {
|
||||
height: 100%;
|
||||
width: 1px;
|
||||
background: linear-gradient(to top, transparent 80%, #777);
|
||||
left: -1px;
|
||||
top: 0px;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.top {
|
||||
height: 1px;
|
||||
width: 100%;
|
||||
background: linear-gradient(to left, transparent 75%, #777);
|
||||
left: -1px;
|
||||
top: -1px;
|
||||
position: absolute;
|
||||
transform: translateY(-0px);
|
||||
}
|
||||
|
||||
@keyframes wiggle {
|
||||
from { transform: rotate(-12deg); }
|
||||
50% { transform: rotate(12deg); }
|
||||
to { transform: rotate(-12deg); }
|
||||
}
|
||||
|
||||
.submit {
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-around;
|
||||
width: 100%;
|
||||
button {
|
||||
border-radius: 5px;
|
||||
color: #f64030;
|
||||
max-width: 90%;
|
||||
padding: 2vh;
|
||||
&[name="confirm"] {
|
||||
background-color: #eee;
|
||||
border-radius: 0;
|
||||
border: 7px solid #ccc;
|
||||
color: #d68030;
|
||||
font-family: Strike;
|
||||
font-size: 10vw;
|
||||
height: 50%;
|
||||
padding: 3.5rem;
|
||||
transition: box-shadow 200ms;
|
||||
&:hover {
|
||||
box-shadow: 1px 1px 5px black;
|
||||
}
|
||||
}
|
||||
&[name="cancel"] {
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
color: #777;
|
||||
font-size: 5vw;
|
||||
height: 20%;
|
||||
text-underline-offset: 0.4em;
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.inside {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
86
app/black-card-text/index.jsx
Normal file
86
app/black-card-text/index.jsx
Normal file
|
@ -0,0 +1,86 @@
|
|||
import PropTypes from 'prop-types';
|
||||
|
||||
import FluidText from '#fluid-text';
|
||||
import {useGame} from '#hooks';
|
||||
|
||||
import styles from './index.module.css';
|
||||
|
||||
const capitalize = (string) => string.slice(0, 1).toUpperCase() + string.slice(1);
|
||||
|
||||
function BlackCardText(props) {
|
||||
const {answers} = props;
|
||||
const game = useGame();
|
||||
const parts = game.blackCard.split('_');
|
||||
const blanksCount = parts.length - 1;
|
||||
const decoratedText = parts.reduce((r, part, i) => (
|
||||
<>
|
||||
{r}
|
||||
{
|
||||
0 === i
|
||||
? ''
|
||||
: (
|
||||
<span
|
||||
className={[
|
||||
styles.answer,
|
||||
!answers[i - 1] && styles.blank,
|
||||
].filter(Boolean).join(' ')}
|
||||
>
|
||||
{
|
||||
// Capitalize the first letter if the blank is the first letter.
|
||||
(
|
||||
(
|
||||
(
|
||||
1 === i
|
||||
&& '_'.charCodeAt(0) === game.blackCard.charCodeAt(0)
|
||||
)
|
||||
|| '. ' === parts[i - 1]
|
||||
)
|
||||
? capitalize
|
||||
: (_) => _
|
||||
)(answers[i - 1] || '_____')
|
||||
}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
{part}
|
||||
</>
|
||||
), '');
|
||||
return (
|
||||
<div className={styles['black-card-text']}>
|
||||
<FluidText>
|
||||
{
|
||||
0 === blanksCount
|
||||
? (
|
||||
<>
|
||||
{game.blackCard}
|
||||
<div className="footer">
|
||||
<span
|
||||
className={[
|
||||
styles.answer,
|
||||
!answers[0] && styles.blank,
|
||||
].filter(Boolean).join(' ')}
|
||||
>
|
||||
{answers[0] ? `${capitalize(answers[0])}` : '_____'}
|
||||
</span>
|
||||
.
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
: decoratedText
|
||||
}
|
||||
</FluidText>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
BlackCardText.defaultProps = {
|
||||
answers: [],
|
||||
style: {},
|
||||
};
|
||||
|
||||
BlackCardText.propTypes = {
|
||||
answers: PropTypes.arrayOf(PropTypes.string),
|
||||
style: PropTypes.shape({}),
|
||||
};
|
||||
|
||||
export default BlackCardText;
|
27
app/black-card-text/index.module.css
Normal file
27
app/black-card-text/index.module.css
Normal file
|
@ -0,0 +1,27 @@
|
|||
.black-card-text {
|
||||
font-size: 3vh;
|
||||
> div {
|
||||
hyphens: auto;
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
||||
.answer {
|
||||
color: #fff;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 0.125em;
|
||||
&.blank {
|
||||
font-family: arial;
|
||||
}
|
||||
}
|
||||
|
||||
.footer {
|
||||
margin-top: 1em;
|
||||
}
|
||||
|
||||
.number {
|
||||
font-family: caladea;
|
||||
font-style: italic;
|
||||
font-weight: bold;
|
||||
font-size: 0.85em;
|
||||
}
|
18
app/entry.client.jsx
Normal file
18
app/entry.client.jsx
Normal file
|
@ -0,0 +1,18 @@
|
|||
/**
|
||||
* By default, Remix will handle hydrating your app on the client for you.
|
||||
* You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨
|
||||
* For more information, see https://remix.run/file-conventions/entry.client
|
||||
*/
|
||||
|
||||
import { RemixBrowser } from "@remix-run/react";
|
||||
import { startTransition, StrictMode } from "react";
|
||||
import { hydrateRoot } from "react-dom/client";
|
||||
|
||||
startTransition(() => {
|
||||
hydrateRoot(
|
||||
document,
|
||||
<StrictMode>
|
||||
<RemixBrowser />
|
||||
</StrictMode>
|
||||
);
|
||||
});
|
155
app/entry.server.jsx
Normal file
155
app/entry.server.jsx
Normal file
|
@ -0,0 +1,155 @@
|
|||
import {readdir, readFile} from 'node:fs/promises';
|
||||
import {basename, join} from 'node:path';
|
||||
import {PassThrough} from 'node:stream';
|
||||
|
||||
import {createReadableStreamFromReadable} from '@remix-run/node';
|
||||
import {RemixServer} from '@remix-run/react';
|
||||
import {isbot} from 'isbot';
|
||||
import color from 'picocolors';
|
||||
import {renderToPipeableStream} from 'react-dom/server';
|
||||
|
||||
import {readJsonPacks} from '#state/cards';
|
||||
import {Game} from '#state/game';
|
||||
|
||||
const ABORT_DELAY = 5_000;
|
||||
|
||||
(async () => {
|
||||
const {createGameServerLoop} = await import('#state/game.server');
|
||||
const loadStart = Date.now();
|
||||
[Game.packs, Game.tokens] = await Promise.all([
|
||||
readdir('data/packs').then(async (paths) => (
|
||||
(await Promise.all(
|
||||
paths
|
||||
.filter((path) => path.endsWith('.json'))
|
||||
.map(async (path) => (
|
||||
readJsonPacks(JSON.parse(await readFile(join('data/packs', path))))
|
||||
)),
|
||||
))
|
||||
.flat()
|
||||
)),
|
||||
readdir('data/tokens').then((paths) => (
|
||||
paths
|
||||
.filter((path) => path.endsWith('.json'))
|
||||
.reduce(async (tokens, path) => ({
|
||||
...(await tokens),
|
||||
[basename(path, '.json')]: JSON.parse(await readFile(join('data/tokens', path))),
|
||||
}), {})
|
||||
)),
|
||||
]);
|
||||
Game.packs = Game.packs.map((pack, i) => ({...pack, id: i}));
|
||||
console.log(
|
||||
'Loaded %s packs and %s tokens in %s ms',
|
||||
color.yellow(Game.packs.length),
|
||||
color.yellow(Object.keys(Game.tokens).length),
|
||||
color.cyan(Date.now() - loadStart),
|
||||
);
|
||||
createGameServerLoop();
|
||||
})();
|
||||
|
||||
export default function handleRequest(
|
||||
request,
|
||||
responseStatusCode,
|
||||
responseHeaders,
|
||||
remixContext
|
||||
) {
|
||||
return isbot(request.headers.get('user-agent') || '')
|
||||
? handleBotRequest(
|
||||
request,
|
||||
responseStatusCode,
|
||||
responseHeaders,
|
||||
remixContext
|
||||
)
|
||||
: handleBrowserRequest(
|
||||
request,
|
||||
responseStatusCode,
|
||||
responseHeaders,
|
||||
remixContext
|
||||
);
|
||||
}
|
||||
|
||||
function handleBotRequest(
|
||||
request,
|
||||
responseStatusCode,
|
||||
responseHeaders,
|
||||
remixContext
|
||||
) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const {pipe, abort} = renderToPipeableStream(
|
||||
<RemixServer
|
||||
context={remixContext}
|
||||
url={request.url}
|
||||
abortDelay={ABORT_DELAY}
|
||||
/>,
|
||||
{
|
||||
onAllReady() {
|
||||
const body = new PassThrough();
|
||||
const stream = createReadableStreamFromReadable(body);
|
||||
|
||||
responseHeaders.set('Content-Type', 'text/html');
|
||||
|
||||
resolve(
|
||||
new Response(stream, {
|
||||
headers: responseHeaders,
|
||||
status: responseStatusCode,
|
||||
})
|
||||
);
|
||||
|
||||
pipe(body);
|
||||
},
|
||||
onShellError(error) {
|
||||
reject(error);
|
||||
},
|
||||
onError(error) {
|
||||
responseStatusCode = 500;
|
||||
console.error(error);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
setTimeout(abort, ABORT_DELAY);
|
||||
});
|
||||
}
|
||||
|
||||
function handleBrowserRequest(
|
||||
request,
|
||||
responseStatusCode,
|
||||
responseHeaders,
|
||||
remixContext
|
||||
) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const {pipe, abort} = renderToPipeableStream(
|
||||
<RemixServer
|
||||
context={remixContext}
|
||||
url={request.url}
|
||||
abortDelay={ABORT_DELAY}
|
||||
/>,
|
||||
{
|
||||
async onShellReady() {
|
||||
const {requestBody} = await import('#state/game.server');
|
||||
const body = await requestBody(request);
|
||||
const stream = createReadableStreamFromReadable(body);
|
||||
|
||||
responseHeaders.set('Content-Type', 'text/html');
|
||||
|
||||
resolve(
|
||||
new Response(stream, {
|
||||
headers: responseHeaders,
|
||||
status: responseStatusCode,
|
||||
})
|
||||
);
|
||||
|
||||
pipe(body);
|
||||
},
|
||||
onShellError(error) {
|
||||
reject(error);
|
||||
},
|
||||
onError(error) {
|
||||
console.error(error);
|
||||
responseStatusCode = 500;
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
setTimeout(abort, ABORT_DELAY);
|
||||
});
|
||||
}
|
60
app/fluid-text/index.jsx
Normal file
60
app/fluid-text/index.jsx
Normal file
|
@ -0,0 +1,60 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import {useEffect, useRef} from 'react';
|
||||
|
||||
import styles from './index.module.css';
|
||||
|
||||
function FluidText({children}) {
|
||||
const ref = useRef();
|
||||
useEffect(() => {
|
||||
const {current} = ref;
|
||||
if (!current) {
|
||||
return;
|
||||
}
|
||||
function resize() {
|
||||
const started = Date.now();
|
||||
current.style.opacity = 0;
|
||||
const {parentNode} = current;
|
||||
const doesContain = () => (
|
||||
current.clientWidth <= parentNode.clientWidth
|
||||
&& current.clientHeight <= parentNode.clientHeight
|
||||
);
|
||||
let fontSizeInPx = 1;
|
||||
const setFontSize = () => {
|
||||
current.style.fontSize = `${fontSizeInPx}px`;
|
||||
};
|
||||
setFontSize();
|
||||
while (doesContain() && (Date.now() - started) < 500) {
|
||||
fontSizeInPx += 16;
|
||||
setFontSize();
|
||||
}
|
||||
fontSizeInPx -= 16;
|
||||
setFontSize();
|
||||
while (doesContain() && (Date.now() - started) < 500) {
|
||||
fontSizeInPx += 1;
|
||||
setFontSize();
|
||||
}
|
||||
fontSizeInPx -= 1;
|
||||
setFontSize();
|
||||
current.style.opacity = 1;
|
||||
}
|
||||
resize();
|
||||
window.addEventListener('resize', resize);
|
||||
return () => {
|
||||
window.removeEventListener('resize', resize);
|
||||
};
|
||||
}, [children, ref])
|
||||
return (
|
||||
<div className={styles.fluidText}>
|
||||
<div className={styles.resize} ref={ref}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
FluidText.propTypes = {
|
||||
children: PropTypes.node,
|
||||
};
|
||||
|
||||
export default FluidText;
|
||||
|
13
app/fluid-text/index.module.css
Normal file
13
app/fluid-text/index.module.css
Normal file
|
@ -0,0 +1,13 @@
|
|||
.fluidText {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
text-align: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.resize {
|
||||
display: inline-block;
|
||||
}
|
18
app/fluid-text/initializer.jsx
Normal file
18
app/fluid-text/initializer.jsx
Normal file
|
@ -0,0 +1,18 @@
|
|||
import styles from './index.module.css';
|
||||
|
||||
export default function Initializer() {
|
||||
return (
|
||||
<script
|
||||
// so dramatic...
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
{
|
||||
const styleSheet = document.createElement('style');
|
||||
styleSheet.innerText = '.${styles.resize} { opacity: 0; }';
|
||||
document.head.appendChild(styleSheet);
|
||||
}
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
BIN
app/fonts/caladea.woff
Normal file
BIN
app/fonts/caladea.woff
Normal file
Binary file not shown.
BIN
app/fonts/flashrogers.ttf
Normal file
BIN
app/fonts/flashrogers.ttf
Normal file
Binary file not shown.
BIN
app/fonts/flashrogerschrome.ttf
Normal file
BIN
app/fonts/flashrogerschrome.ttf
Normal file
Binary file not shown.
BIN
app/fonts/flashrogershalf.ttf
Normal file
BIN
app/fonts/flashrogershalf.ttf
Normal file
Binary file not shown.
BIN
app/fonts/flashrogerspunch.ttf
Normal file
BIN
app/fonts/flashrogerspunch.ttf
Normal file
Binary file not shown.
19
app/fonts/index.css
Normal file
19
app/fonts/index.css
Normal file
|
@ -0,0 +1,19 @@
|
|||
@font-face {
|
||||
font-family: "Shark";
|
||||
src: url("./flashrogers.ttf") format("truetype");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Smack";
|
||||
src: url("./flashrogerspunch.ttf") format("truetype");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Strike";
|
||||
src: url("./flashrogershalf.ttf") format("truetype");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Line";
|
||||
src: url("./flashrogerschrome.ttf") format("truetype");
|
||||
}
|
BIN
app/fonts/shark.ttf
Normal file
BIN
app/fonts/shark.ttf
Normal file
Binary file not shown.
BIN
app/fonts/smack.ttf
Normal file
BIN
app/fonts/smack.ttf
Normal file
Binary file not shown.
5
app/hooks/context.js
Normal file
5
app/hooks/context.js
Normal file
|
@ -0,0 +1,5 @@
|
|||
import {createContext} from 'react';
|
||||
|
||||
export const GameContext = createContext(undefined);
|
||||
export const SelectionContext = createContext(undefined);
|
||||
export const SessionContext = createContext(undefined);
|
4
app/hooks/index.js
Normal file
4
app/hooks/index.js
Normal file
|
@ -0,0 +1,4 @@
|
|||
export {default as useGame} from './use-game';
|
||||
export {default as useIsHydrated} from './use-is-hydrated';
|
||||
export {default as useSelection} from './use-selection';
|
||||
export {default as useSession} from './use-session';
|
7
app/hooks/use-game.js
Normal file
7
app/hooks/use-game.js
Normal file
|
@ -0,0 +1,7 @@
|
|||
import {useContext} from 'react';
|
||||
|
||||
import {GameContext} from './context';
|
||||
|
||||
export default function useGame() {
|
||||
return useContext(GameContext);
|
||||
}
|
7
app/hooks/use-is-hydrated.js
Normal file
7
app/hooks/use-is-hydrated.js
Normal file
|
@ -0,0 +1,7 @@
|
|||
import {useState, useEffect, useLayoutEffect} from "react";
|
||||
|
||||
export default function useIsHydrated() {
|
||||
const [isHydrated, setIsHydrated] = useState(false);
|
||||
('undefined' === typeof window ? useEffect : useLayoutEffect)(() => setIsHydrated(true), []);
|
||||
return isHydrated;
|
||||
}
|
7
app/hooks/use-selection.js
Normal file
7
app/hooks/use-selection.js
Normal file
|
@ -0,0 +1,7 @@
|
|||
import {useContext} from 'react';
|
||||
|
||||
import {SelectionContext} from './context';
|
||||
|
||||
export default function useSelection() {
|
||||
return useContext(SelectionContext);
|
||||
}
|
7
app/hooks/use-session.js
Normal file
7
app/hooks/use-session.js
Normal file
|
@ -0,0 +1,7 @@
|
|||
import {useContext} from 'react';
|
||||
|
||||
import {SessionContext} from './context';
|
||||
|
||||
export default function useSession() {
|
||||
return useContext(SessionContext);
|
||||
}
|
106
app/root.css
Normal file
106
app/root.css
Normal file
|
@ -0,0 +1,106 @@
|
|||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
*:focus {
|
||||
box-shadow: 0 0 2px 0 #d68030ff;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
--default-font: Shark, 'Times New Roman', Times, serif;
|
||||
font-family: var(--default-font);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #777 #333;
|
||||
}
|
||||
::-webkit-scrollbar {
|
||||
width: 12px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: #333;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background-color: #777;
|
||||
border-radius: 20px;
|
||||
border: 3px solid #333;
|
||||
}
|
||||
|
||||
button {
|
||||
font-family: var(--default-font);
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
||||
monospace;
|
||||
}
|
||||
|
||||
html, body, #root {
|
||||
background-color: #111111;
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
input {
|
||||
border: none;
|
||||
}
|
||||
|
||||
fieldset {
|
||||
background-color: #151515;
|
||||
border: 1px solid #000000;
|
||||
display: inline-block;
|
||||
margin: 0;
|
||||
position: relative;
|
||||
top: -0.5em;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
form {
|
||||
line-height: 0;
|
||||
}
|
||||
|
||||
button,
|
||||
input[type="checkbox"],
|
||||
input[type="checkbox"] + label {
|
||||
&:not(:disabled) {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
fieldset {
|
||||
margin-bottom: 1em;
|
||||
padding: 0.5em;
|
||||
}
|
||||
|
||||
label {
|
||||
color: #ffffff;
|
||||
font-family: var(--default-font);
|
||||
font-size: 1em;
|
||||
text-transform: uppercase;
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
select {
|
||||
background: #222222;
|
||||
border: 1px solid #000000;
|
||||
color: #ffffff;
|
||||
font-size: 1em;
|
||||
line-height: 0.75em;
|
||||
margin: 0 1em;
|
||||
appearance: none;
|
||||
-moz-appearance: none;
|
||||
-webkit-appearance: none;
|
||||
padding: 0.5em;
|
||||
}
|
||||
|
||||
:global {
|
||||
@font-face {
|
||||
font-family: "Caladea";
|
||||
src: url("./fonts/caladea.woff") format("woff");
|
||||
}
|
||||
}
|
42
app/root.jsx
Normal file
42
app/root.jsx
Normal file
|
@ -0,0 +1,42 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import {
|
||||
Links,
|
||||
Meta,
|
||||
Outlet,
|
||||
Scripts,
|
||||
ScrollRestoration,
|
||||
} from "@remix-run/react";
|
||||
|
||||
import './root.css';
|
||||
import locals from './root.module.css';
|
||||
|
||||
import FluidTextInitializer from './fluid-text/initializer';
|
||||
import NumberInitializer from './routes/play.$gameKey/bar/timeout/number-initializer';
|
||||
|
||||
export function Layout({children}) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charSet="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<Meta />
|
||||
<Links />
|
||||
</head>
|
||||
<body className={locals.terrible}>
|
||||
<FluidTextInitializer />
|
||||
<NumberInitializer />
|
||||
{children}
|
||||
<ScrollRestoration />
|
||||
<Scripts />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
Layout.propTypes = {
|
||||
children: PropTypes.node,
|
||||
};
|
||||
|
||||
export default function App() {
|
||||
return <Outlet />;
|
||||
}
|
4
app/root.module.css
Normal file
4
app/root.module.css
Normal file
|
@ -0,0 +1,4 @@
|
|||
.terrible {
|
||||
background-color: #222222;
|
||||
color: #AAAAAA;
|
||||
}
|
131
app/routes/_intro._index/landing.module.css
Normal file
131
app/routes/_intro._index/landing.module.css
Normal file
|
@ -0,0 +1,131 @@
|
|||
.landing {
|
||||
background: inherit;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
justify-content: space-between;
|
||||
overflow: hidden;
|
||||
padding: 8vh 8vw 4vh;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.doSide {
|
||||
font-size: 0.5em;
|
||||
}
|
||||
|
||||
.terribleSide {
|
||||
color: #d68030;
|
||||
font-family: Strike;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.title {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 40%;
|
||||
font-family: Line;
|
||||
text-align: center;
|
||||
div {
|
||||
display: inline-block;
|
||||
line-height: 1;
|
||||
font-size: 18vw;
|
||||
@media(min-width: 50em) {
|
||||
font-size: 145px;
|
||||
}
|
||||
@media(max-height: 55rem) {
|
||||
font-size: 13vh;
|
||||
}
|
||||
text-shadow:
|
||||
0.125vw 0 0 black,
|
||||
0 0.125vw 0 black,
|
||||
-0.125vw 0 0 black,
|
||||
0 -0.125vw 0 black,
|
||||
0 0 30px #000,
|
||||
0 0 7px #000,
|
||||
0 0 7px #000,
|
||||
0 0 7px #000,
|
||||
0 0 7px #000,
|
||||
0 0 7px #000,
|
||||
0 0 7px #000,
|
||||
0 0 7px #000,
|
||||
0 0 7px #000,
|
||||
0 0 7px #000,
|
||||
0 0 7px #000,
|
||||
0 0 7px #000,
|
||||
0 0 30px #000,
|
||||
0 0 30px #000
|
||||
;
|
||||
}
|
||||
transform: translateY(-20px) scaleY(1.2) perspective(100px) rotateX(25deg) translateZ(-0.5vw);
|
||||
}
|
||||
|
||||
.buttons {
|
||||
align-items: center;
|
||||
background: inherit;
|
||||
display: flex;
|
||||
flex-basis: 50%;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.buttons a {
|
||||
background: inherit;
|
||||
color: #aaa;
|
||||
max-width: 70vw;
|
||||
overflow: hidden;
|
||||
padding: 2vh 8vw;
|
||||
position: relative;
|
||||
text-align: center;
|
||||
text-transform: uppercase;
|
||||
text-decoration: none;
|
||||
width: 100%;
|
||||
&:first-child {
|
||||
flex-basis: 55%;
|
||||
}
|
||||
&:nth-child(2) {
|
||||
flex-basis: 35%;
|
||||
}
|
||||
&:before {
|
||||
content: ' ';
|
||||
background: inherit;
|
||||
position: absolute;
|
||||
left: -4vw;
|
||||
right: 0;
|
||||
top: -4vh;
|
||||
height: calc(100% + 8vh);
|
||||
width: calc(100% + 8vw);
|
||||
bottom: 0;
|
||||
filter: blur(0.025em) brightness(0.4);
|
||||
transition: 0.4s filter;
|
||||
}
|
||||
&:hover {
|
||||
&:before {
|
||||
filter: blur(0.25em) brightness(0.6);
|
||||
}
|
||||
text-shadow: 0 0 5em #ffff0022;
|
||||
color: #ff0;
|
||||
}
|
||||
div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.createButton {
|
||||
font-size: 10vw;
|
||||
@media(min-width: 50em) {
|
||||
font-size: 6vw;
|
||||
}
|
||||
}
|
||||
|
||||
.joinButton {
|
||||
font-size: 8vw;
|
||||
@media(min-width: 50em) {
|
||||
font-size: 4vw;
|
||||
}
|
||||
}
|
||||
|
32
app/routes/_intro._index/route.jsx
Normal file
32
app/routes/_intro._index/route.jsx
Normal file
|
@ -0,0 +1,32 @@
|
|||
import {Link} from 'react-router-dom';
|
||||
|
||||
import styles from './landing.module.css';
|
||||
|
||||
export const meta = () => {
|
||||
return [{title: 'Welcome | Do Terrible'}];
|
||||
};
|
||||
|
||||
export default function Landing() {
|
||||
return (
|
||||
<div className={styles.landing}>
|
||||
<div className={styles.title}>
|
||||
<div className={styles.doSide}>Do</div>
|
||||
<div className={styles.terribleSide}>Terrible</div>
|
||||
</div>
|
||||
<div className={styles.buttons}>
|
||||
<Link
|
||||
className={styles.createButton}
|
||||
to="/create"
|
||||
>
|
||||
<div>Create a game</div>
|
||||
</Link>
|
||||
<Link
|
||||
className={styles.joinButton}
|
||||
to="/join"
|
||||
>
|
||||
<div>Join a game</div>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
3
app/routes/_intro.create/config.js
Normal file
3
app/routes/_intro.create/config.js
Normal file
|
@ -0,0 +1,3 @@
|
|||
export const scoreToWin = ['20', '15', '10', '5'];
|
||||
export const secondsPerRound = ['120', '90', '60', '30'];
|
||||
export const bots = ['0', '1', '2', '3', '4'];
|
257
app/routes/_intro.create/index.module.css
Normal file
257
app/routes/_intro.create/index.module.css
Normal file
|
@ -0,0 +1,257 @@
|
|||
.createWrapper {
|
||||
background-attachment: fixed;
|
||||
background-position-x: center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: cover;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
justify-content: center;
|
||||
width: 100vw;
|
||||
}
|
||||
|
||||
.create {
|
||||
font-size: 2.5vh;
|
||||
height: 100vh;
|
||||
overflow: auto;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.form {
|
||||
background-color: rgba(0, 0, 0, 0.7);
|
||||
box-shadow: 0 0 20px black;
|
||||
margin: auto;
|
||||
max-width: 60rem;
|
||||
> :nth-child(1), > :nth-child(3) {
|
||||
padding: 4vh 0;
|
||||
}
|
||||
}
|
||||
|
||||
.errors {
|
||||
color: red;
|
||||
font-size: 1.5em;
|
||||
line-height: 1;
|
||||
margin-top: 2rem;
|
||||
animation: neon 0.6s infinite;
|
||||
}
|
||||
|
||||
@keyframes neon {
|
||||
0% { text-decoration: none; opacity: 0.6; }
|
||||
50% { text-decoration: underline; opacity: 1; }
|
||||
to { opacity: 0.6; }
|
||||
}
|
||||
|
||||
.create fieldset,
|
||||
.create [type="submit"]
|
||||
{
|
||||
background: inherit;
|
||||
border: none;
|
||||
padding: 2vh 2vw;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.create h2 {
|
||||
font-family: Caladea, 'Times New Roman', Times, serif;
|
||||
font-size: 3vh;
|
||||
}
|
||||
|
||||
.create {
|
||||
h1, h2 {
|
||||
font-weight: normal;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.create form {
|
||||
font-size: 2.5vh;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.create fieldset {
|
||||
text-align: center;
|
||||
text-shadow:
|
||||
1px 0 1px black,
|
||||
0 1px 1px black,
|
||||
-1px 0 1px black,
|
||||
0 -1px 1px black,
|
||||
;
|
||||
}
|
||||
|
||||
.create label {
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.fieldsets {
|
||||
padding: 0 4vw;
|
||||
transition: max-height 0.5s;
|
||||
|
||||
fieldset {
|
||||
top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.packsWrapper legend {
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.create button {
|
||||
background: #151515;
|
||||
border: 1px solid #000000;
|
||||
color: #d68030;
|
||||
font-family: Strike;
|
||||
font-size: 5vh;
|
||||
height: 18vh;
|
||||
margin: 0 auto;
|
||||
padding: 6vh;
|
||||
text-transform: uppercase;
|
||||
width: 100%;
|
||||
|
||||
&:not(:disabled) {
|
||||
animation: glow-pulse 5s infinite;
|
||||
}
|
||||
}
|
||||
|
||||
.create button .text {
|
||||
border: 4px solid #b97106;
|
||||
padding: 17px;
|
||||
text-align: center;
|
||||
&:before {
|
||||
content: '\00a0\00a0\00a0 ';
|
||||
opacity: 0;
|
||||
transition: 0.2s opacity;
|
||||
}
|
||||
&:after {
|
||||
content: ' \00a0\00a0';
|
||||
opacity: 0;
|
||||
transition: 0.2s opacity;
|
||||
}
|
||||
&:hover {
|
||||
border-color: #d6803077;
|
||||
&:before {
|
||||
content: '> ';
|
||||
display: inline;
|
||||
opacity: 1;
|
||||
}
|
||||
&:after {
|
||||
content: ' <';
|
||||
display: inline;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.create button:hover {
|
||||
&:before {
|
||||
filter: blur(0.05em) brightness(0.5);
|
||||
}
|
||||
}
|
||||
|
||||
.create .packs .error {
|
||||
color: #ff0000;
|
||||
filter: blur(0);
|
||||
flex-basis: 100%;
|
||||
font-size: 2vh;
|
||||
margin: 0 0 3vh;
|
||||
animation: bling 0.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes bling {
|
||||
from { color: #ff0000; }
|
||||
50% { color: #ff9900; }
|
||||
to { color: #ff0000; }
|
||||
}
|
||||
|
||||
.create {
|
||||
[disabled] {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes glow-pulse {
|
||||
from { color: #d68030; text-shadow: 0 0 3vh transparent; }
|
||||
49% { color: #d68030; text-shadow: 0 0 3vh transparent; }
|
||||
50% { color: #d68030; text-shadow: 0 0 3vh transparent; }
|
||||
85.1% { color: #d3d630; text-shadow: 0 0 1.5vh #d68030; }
|
||||
94.1% { color: #d68030; text-shadow: 0 0 12vh #d3d63022; }
|
||||
95.1% { color: #d68030; text-shadow: 0 0 15vh #d3d63022; }
|
||||
to { color: #d68030; text-shadow: 0 0 3vh transparent; }
|
||||
}
|
||||
|
||||
.description {
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 0.125em;
|
||||
}
|
||||
|
||||
.packsWrapper {
|
||||
margin-bottom: 3vh;
|
||||
padding: 2vh;
|
||||
|
||||
.description {
|
||||
color: #ffff77;
|
||||
font-size: 200%;
|
||||
line-height: 1;
|
||||
margin: 0 0 2vh;
|
||||
}
|
||||
}
|
||||
|
||||
.packs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
.limitsWrapper {
|
||||
margin: 0;
|
||||
|
||||
> label {
|
||||
display: inline-block;
|
||||
margin-left: 1em;
|
||||
padding: 1.5vh 1vh;
|
||||
}
|
||||
}
|
||||
|
||||
.limits {
|
||||
padding-left: 2vw;
|
||||
}
|
||||
|
||||
.limitsWrapper > div {
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
.limitsWrapper label {
|
||||
align-items: center;
|
||||
color: #ffffcc;
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
filter: blur(0px);
|
||||
font-family: Verdana, Geneva, Tahoma, sans-serif;
|
||||
justify-content: center;
|
||||
line-height: normal;
|
||||
margin: 1rem 0 0.25rem 0;
|
||||
}
|
||||
|
||||
.limitsWrapper select {
|
||||
color: #ffffcc;
|
||||
font-size: 125%;
|
||||
line-height: 1.25;
|
||||
margin: 0 1rem 0 0.5rem;
|
||||
text-align: center;
|
||||
text-align-last: center;
|
||||
text-shadow: 0 0 1vw black;
|
||||
width: 3em;
|
||||
}
|
||||
|
||||
.limitsWrapper label:focus-within {
|
||||
color: #ffff44;
|
||||
select {
|
||||
color: #ffff00;
|
||||
}
|
||||
}
|
||||
|
||||
.limitsWrapper .description {
|
||||
color: #ffff77;
|
||||
font-size: 200%;
|
||||
line-height: 1;
|
||||
margin: 0 0 2vh;
|
||||
}
|
36
app/routes/_intro.create/pack-choice.jsx
Normal file
36
app/routes/_intro.create/pack-choice.jsx
Normal file
|
@ -0,0 +1,36 @@
|
|||
import PropTypes from 'prop-types';
|
||||
|
||||
import {useState} from 'react';
|
||||
|
||||
import styles from './pack-choice.module.css';
|
||||
|
||||
export default function PackChoice(props) {
|
||||
const {pack} = props;
|
||||
const [checked, setChecked] = useState(pack.default);
|
||||
const id = `pack-choice-${pack.id}`;
|
||||
return (
|
||||
<span className={[styles.pack, styles.checkbox].join(' ')}>
|
||||
<input
|
||||
aria-label={pack.label}
|
||||
checked={checked}
|
||||
id={id}
|
||||
name="packs"
|
||||
onChange={(event) => setChecked(event.target.checked)}
|
||||
title={pack.label}
|
||||
type="checkbox"
|
||||
value={pack.id}
|
||||
/>
|
||||
<label htmlFor={id} title={pack.label}>
|
||||
<div className={styles.labelText}>{pack.label}</div>
|
||||
</label>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
PackChoice.propTypes = {
|
||||
pack: PropTypes.shape({
|
||||
default: PropTypes.bool,
|
||||
id: PropTypes.number,
|
||||
label: PropTypes.string,
|
||||
}).isRequired,
|
||||
};
|
71
app/routes/_intro.create/pack-choice.module.css
Normal file
71
app/routes/_intro.create/pack-choice.module.css
Normal file
|
@ -0,0 +1,71 @@
|
|||
.checkbox input[type="checkbox"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.checkbox label {
|
||||
display: inline-block;
|
||||
font-family: Verdana, Geneva, Tahoma, sans-serif;
|
||||
line-height: normal;
|
||||
padding-left: 1.75em;
|
||||
position: relative;
|
||||
white-space: inherit;
|
||||
}
|
||||
|
||||
.checkbox label::before,
|
||||
.checkbox label::after {
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.checkbox input[type="checkbox"]:focus + label::before {
|
||||
outline: rgb(59, 153, 252) auto 5px;
|
||||
}
|
||||
.checkbox label::before{
|
||||
border: 1px solid;
|
||||
content: "";
|
||||
height: 1.25em;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 1.25em;
|
||||
}
|
||||
|
||||
.checkbox input[type="checkbox"] + label::after {
|
||||
content: none;
|
||||
}
|
||||
.checkbox input[type="checkbox"]:checked + label {
|
||||
color: #ffffcc;
|
||||
}
|
||||
.checkbox input[type="checkbox"]:checked + label::after {
|
||||
animation: heartbeat 1s infinite;
|
||||
color: #ff0000;
|
||||
content: "💔";
|
||||
left: calc(0.625em + 1px);
|
||||
top: 1rem;
|
||||
transform-origin: 0% 0%;
|
||||
}
|
||||
@keyframes heartbeat {
|
||||
0% {
|
||||
transform: rotate(0) scale(0.9) translate(-50%, -50%);
|
||||
}
|
||||
12% {
|
||||
transform: rotate(-7deg) scale(0.7) translate(-50%, -50%);
|
||||
}
|
||||
25% {
|
||||
transform: rotate(0) scale(0.8) translate(-50%, -50%);
|
||||
}
|
||||
37%, 50% {
|
||||
transform: rotate(2deg) scale(0.7) translate(-50%, -50%);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(0) scale(0.9) translate(-50%, -50%);
|
||||
}
|
||||
}
|
||||
|
||||
.pack {
|
||||
display: inline-block;
|
||||
padding: 0.4em 1vw;
|
||||
}
|
||||
|
||||
.pack label:hover {
|
||||
color: #aaa;
|
||||
}
|
158
app/routes/_intro.create/route.jsx
Normal file
158
app/routes/_intro.create/route.jsx
Normal file
|
@ -0,0 +1,158 @@
|
|||
import {
|
||||
Form,
|
||||
json,
|
||||
redirect,
|
||||
useActionData,
|
||||
useLoaderData,
|
||||
useNavigation,
|
||||
useSearchParams,
|
||||
} from '@remix-run/react';
|
||||
|
||||
import FluidText from '#fluid-text';
|
||||
import {Game} from '#state/game';
|
||||
import {juggleSession, loadSession} from '#state/session';
|
||||
|
||||
import PackChoice from './pack-choice';
|
||||
import styles from './index.module.css';
|
||||
|
||||
import * as config from './config';
|
||||
|
||||
export const action = async ({request}) => {
|
||||
const {createGame} = await import('#state/game.server');
|
||||
const formData = await request.formData();
|
||||
const errors = Object.entries(config)
|
||||
.reduce((r, [k, v]) => ({
|
||||
...r,
|
||||
...!v.includes(formData.get(k)) && {[k]: `Invalid selection for ${k}`}
|
||||
}), Game.check(formData));
|
||||
if (Object.keys(errors).length > 0) {
|
||||
return json({errors});
|
||||
}
|
||||
const session = await loadSession(request);
|
||||
if (!session.id) {
|
||||
throw new Response('', {status: 400});
|
||||
}
|
||||
return redirect(`/play/${await createGame(session, formData)}`);
|
||||
};
|
||||
|
||||
export const meta = () => {
|
||||
return [{title: 'Create | Do Terrible'}];
|
||||
};
|
||||
|
||||
function isDefaultPack(name) {
|
||||
return [
|
||||
'cha0s from the sky',
|
||||
'CAH Base Set',
|
||||
'CAH: Main Deck',
|
||||
'CAH: Human Pack',
|
||||
'CAH: Blue Box Expansion',
|
||||
'CAH: Green Box Expansion',
|
||||
'CAH: Red Box Expansion',
|
||||
'CAH: Box Expansion',
|
||||
'CAH: 2000s Nostalgia Pack',
|
||||
'CAH: College Pack',
|
||||
'CAH: First Expansion',
|
||||
'CAH: Second Expansion',
|
||||
'CAH: Third Expansion',
|
||||
'CAH: Fourth Expansion',
|
||||
'CAH: Fifth Expansion',
|
||||
'CAH: Sixth Expansion',
|
||||
].includes(name);
|
||||
}
|
||||
|
||||
export async function loader({request}) {
|
||||
return json({
|
||||
packs: Game.packs.map(({name, id}) => ({default: isDefaultPack(name), label: name, id})),
|
||||
session: await juggleSession(request),
|
||||
});
|
||||
}
|
||||
|
||||
export default function CreateSpace() {
|
||||
const actionData = useActionData();
|
||||
const {packs, session} = useLoaderData();
|
||||
const navigation = useNavigation();
|
||||
const [searchParams] = useSearchParams();
|
||||
const errors = Object.entries(actionData?.errors || {})
|
||||
const sortedPacks = packs.sort((l, r) => {
|
||||
if (l.default === r.default) {
|
||||
return l.label < r.label ? -1 : 1;
|
||||
}
|
||||
return l.default ? -1 : 1;
|
||||
});
|
||||
const renderedPackChoices = sortedPacks.map(
|
||||
(pack) => <PackChoice pack={pack} key={pack.id} />,
|
||||
);
|
||||
const isCreating = navigation.formAction === '/create';
|
||||
return (
|
||||
<div className={styles.createWrapper}>
|
||||
<div className={styles.create}>
|
||||
<Form method="post">
|
||||
<div className={styles.form}>
|
||||
<div>
|
||||
<button disabled={!session.id || isCreating} type="submit">
|
||||
<FluidText>
|
||||
<div className={styles.text}>
|
||||
{
|
||||
session.id
|
||||
? (isCreating ? 'Starting the game...' : 'Start the game')
|
||||
: 'Enable cookies'
|
||||
}
|
||||
</div>
|
||||
</FluidText>
|
||||
</button>
|
||||
{0 !== session.id && (
|
||||
<p>...or tweak some settings first</p>
|
||||
)}
|
||||
{(errors.length > 0 || searchParams.has('none')) && (
|
||||
<div className={styles.errors}>
|
||||
{errors.map(([key, error]) => <div key={key}>{error}</div>)}
|
||||
{searchParams.has('none') && (
|
||||
<div key="none">No active games may be joined right now. Create one!</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{0 !== session.id && (
|
||||
<div className={styles.fieldsets}>
|
||||
<fieldset className={styles.limitsWrapper}>
|
||||
<div className={styles.description}>Details</div>
|
||||
<div className={styles.limits}>
|
||||
<input name="maxPlayers" type="hidden" value="9" />
|
||||
<label>
|
||||
<span>score to win</span>
|
||||
<select defaultValue={10} name="scoreToWin">
|
||||
{config.scoreToWin.map((value) => (
|
||||
<option key={value} value={value}>{value}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>seconds per round</span>
|
||||
<select defaultValue={120} name="secondsPerRound">
|
||||
{config.secondsPerRound.map((value) => (
|
||||
<option key={value} value={value}>{value}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>bots</span>
|
||||
<select defaultValue={0} name="bots">
|
||||
{config.bots.map((value) => (
|
||||
<option key={value} value={value}>{value}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
<fieldset className={styles.packsWrapper}>
|
||||
<div className={styles.description}>Packs</div>
|
||||
<div className={styles.packs}>{renderedPackChoices}</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
49
app/routes/_intro.join/games/game/index.jsx
Normal file
49
app/routes/_intro.join/games/game/index.jsx
Normal file
|
@ -0,0 +1,49 @@
|
|||
import {Link} from '@remix-run/react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import {State} from '#state/globals';
|
||||
|
||||
import styles from './index.module.css';
|
||||
|
||||
export default function Game(props) {
|
||||
const {game} = props;
|
||||
return (
|
||||
<Link
|
||||
className={styles.game}
|
||||
to={`/play/${game.key}`}
|
||||
>
|
||||
<h2>{game.key}</h2>
|
||||
<div className={styles.stats}>
|
||||
<span className={styles['player-count']}>
|
||||
<span className={styles.number}>{game.playerCount}</span>
|
||||
{' '}
|
||||
{(() => {
|
||||
switch (game.state) {
|
||||
case State.STARTING: return 'just starting';
|
||||
case State.ANSWERING:
|
||||
case State.AWARDING:
|
||||
case State.AWARDED:
|
||||
return 'playing';
|
||||
case State.FINISHED: return 'finished';
|
||||
case State.PAUSED: return 'paused';
|
||||
default: return '';
|
||||
}
|
||||
})()}
|
||||
</span>
|
||||
<span className={styles.spacer}>{' - '}</span>
|
||||
<span className={styles.completed}>
|
||||
progress: <span className={styles.number}>{game.completed * 100}</span>%
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
Game.propTypes = {
|
||||
game: PropTypes.shape({
|
||||
completed: PropTypes.number,
|
||||
key: PropTypes.string,
|
||||
playerCount: PropTypes.number,
|
||||
state: PropTypes.number,
|
||||
}).isRequired,
|
||||
};
|
39
app/routes/_intro.join/games/game/index.module.css
Normal file
39
app/routes/_intro.join/games/game/index.module.css
Normal file
|
@ -0,0 +1,39 @@
|
|||
.game {
|
||||
color: #999;
|
||||
display: block;
|
||||
margin: 1em;
|
||||
max-width: 20em;
|
||||
background-color: rgba(12, 12, 12, 0.8);
|
||||
border: 1px solid #444;
|
||||
padding: 1em;
|
||||
text-decoration: none;
|
||||
&:hover {
|
||||
background-color: rgba(24, 24, 24, 0.9);
|
||||
}
|
||||
}
|
||||
|
||||
.game .stats {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.8em;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
.game .stats .ish {
|
||||
font-size: 0.7em;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.game h2 {
|
||||
font-family: Consolas, monospace;
|
||||
margin: 0.5em 0 1em;
|
||||
}
|
||||
|
||||
.number {
|
||||
color: #d68030;
|
||||
font-family: Caladea, 'Times New Roman', Times, serif;
|
||||
}
|
||||
|
||||
.spacer {
|
||||
margin: 0 0.5rem;
|
||||
}
|
16
app/routes/_intro.join/games/index.jsx
Normal file
16
app/routes/_intro.join/games/index.jsx
Normal file
|
@ -0,0 +1,16 @@
|
|||
import PropTypes from 'prop-types';
|
||||
|
||||
import Game from './game';
|
||||
import styles from './index.module.css';
|
||||
|
||||
export default function Games({games}) {
|
||||
return (
|
||||
<div className={styles.games}>
|
||||
{games.map((game) => <Game key={game.key} game={game} />)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Games.propTypes = {
|
||||
games: PropTypes.arrayOf(Game.propTypes.game),
|
||||
};
|
5
app/routes/_intro.join/games/index.module.css
Normal file
5
app/routes/_intro.join/games/index.module.css
Normal file
|
@ -0,0 +1,5 @@
|
|||
.games {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-evenly;
|
||||
}
|
80
app/routes/_intro.join/index.jsx
Normal file
80
app/routes/_intro.join/index.jsx
Normal file
|
@ -0,0 +1,80 @@
|
|||
import {
|
||||
Form,
|
||||
json,
|
||||
redirect,
|
||||
useLoaderData,
|
||||
useSearchParams,
|
||||
} from '@remix-run/react';
|
||||
|
||||
import styles from './index.module.css';
|
||||
import Games from './games';
|
||||
|
||||
export const meta = () => {
|
||||
return [{title: 'Join | Do Terrible'}];
|
||||
};
|
||||
|
||||
export async function action({request}) {
|
||||
const formData = await request.formData();
|
||||
const gameKey = formData.get('gameKey').toUpperCase();
|
||||
if (!gameKey.match(/^[A-Z]{4}$/)) {
|
||||
throw new Response('', {status: 400});
|
||||
}
|
||||
const {loadGame} = await import('#state/game.server');
|
||||
const game = await loadGame(gameKey);
|
||||
if (!game) {
|
||||
throw redirect(`/join?nope=${gameKey}`);
|
||||
}
|
||||
return redirect(`/play/${gameKey}`);
|
||||
}
|
||||
|
||||
export async function loader() {
|
||||
const {joinList} = await import('#state/game.server');
|
||||
const games = await joinList();
|
||||
if (0 === games.length) {
|
||||
throw redirect('/create?none');
|
||||
}
|
||||
return json({games: await joinList()});
|
||||
}
|
||||
|
||||
export default function Join() {
|
||||
const {games} = useLoaderData();
|
||||
const [searchParams] = useSearchParams();
|
||||
return (
|
||||
<div className={styles.joinWrapper}>
|
||||
<div className={styles.join}>
|
||||
<h1>Join a game</h1>
|
||||
{searchParams.has('full') && (
|
||||
<div className={styles.error}>
|
||||
That game is full, try joining another one!
|
||||
</div>
|
||||
)}
|
||||
{searchParams.has('nope') && (
|
||||
<div className={styles.error}>
|
||||
<code>{searchParams.get('nope')}</code> is not a valid game key
|
||||
</div>
|
||||
)}
|
||||
<Form method="post">
|
||||
<label>
|
||||
<div>Have a code? Stick it in</div>
|
||||
{' '}
|
||||
<input
|
||||
aria-label="Game code"
|
||||
minLength="4"
|
||||
maxLength="4"
|
||||
name="gameKey"
|
||||
pattern="^[a-zA-Z]{4}$"
|
||||
placeholder="CODE"
|
||||
spellCheck={false}
|
||||
size="4"
|
||||
title="A four-letter game code."
|
||||
type="text"
|
||||
/>
|
||||
</label>
|
||||
<button type="submit">Join</button>
|
||||
</Form>
|
||||
<h2 className={styles.jump}>Or, jump into a game</h2>
|
||||
<Games games={games} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
76
app/routes/_intro.join/index.module.css
Normal file
76
app/routes/_intro.join/index.module.css
Normal file
|
@ -0,0 +1,76 @@
|
|||
.joinWrapper {
|
||||
margin: auto;
|
||||
padding-top: 3em;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media(min-width: 64em) {
|
||||
.join {
|
||||
font-size: 1.25em;
|
||||
}
|
||||
}
|
||||
|
||||
.join h1, .join h2 {
|
||||
color: #d68030;
|
||||
font-family: Strike;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.join p {
|
||||
font-size: 1.3em;
|
||||
margin-bottom: 2em;
|
||||
}
|
||||
|
||||
.join button {
|
||||
border: none;
|
||||
font-size: 1em;
|
||||
line-height: 2em;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.join label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
line-height: normal;
|
||||
}
|
||||
|
||||
.join form {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
input {
|
||||
background-color: #222;
|
||||
color: #fff;
|
||||
font-size: 5vh;
|
||||
margin: auto;
|
||||
padding: 1vh 1vw;
|
||||
text-transform: uppercase;
|
||||
width: 19.5vh;
|
||||
}
|
||||
button {
|
||||
background-color: #242424;
|
||||
border: 1px solid black;
|
||||
color: #d68030;
|
||||
font-family: Smack;
|
||||
font-size: 4vh;
|
||||
font-weight: normal;
|
||||
margin-left: 2vw;
|
||||
padding: 0.25vh 2vw;
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
|
||||
.error {
|
||||
color: red;
|
||||
margin-bottom: 2rem;
|
||||
animation: neon 0.6s infinite;
|
||||
}
|
||||
|
||||
@keyframes neon {
|
||||
0% { text-decoration: none; opacity: 0.6; }
|
||||
50% { text-decoration: underline; opacity: 1; }
|
||||
to { opacity: 0.6; }
|
||||
}
|
||||
|
||||
.jump {
|
||||
margin-bottom: 0;
|
||||
}
|
BIN
app/routes/_intro/intro.jpg
Normal file
BIN
app/routes/_intro/intro.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 230 KiB |
17
app/routes/_intro/intro.module.css
Normal file
17
app/routes/_intro/intro.module.css
Normal file
|
@ -0,0 +1,17 @@
|
|||
.introWrapper {
|
||||
background-color: #111111;
|
||||
display: inline-block;
|
||||
min-height: 100vh;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.intro {
|
||||
background-attachment: fixed;
|
||||
background-color: #171717;
|
||||
background-image: url('./intro.jpg');
|
||||
background-position-x: center;
|
||||
background-position-y: center;
|
||||
background-size: cover;
|
||||
background-repeat: repeat;
|
||||
min-height: 100vh;
|
||||
}
|
14
app/routes/_intro/route.jsx
Normal file
14
app/routes/_intro/route.jsx
Normal file
|
@ -0,0 +1,14 @@
|
|||
import {Outlet} from 'react-router-dom';
|
||||
|
||||
import '../../fonts/index.css';
|
||||
import styles from './intro.module.css';
|
||||
|
||||
export default function Intro() {
|
||||
return (
|
||||
<div className={styles.introWrapper}>
|
||||
<div className={styles.intro}>
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
28
app/routes/play.$gameKey.actions/route.jsx
Normal file
28
app/routes/play.$gameKey.actions/route.jsx
Normal file
|
@ -0,0 +1,28 @@
|
|||
import {loadSession} from '#state/session';
|
||||
import {actionSource} from '#utils/action-source';
|
||||
|
||||
export async function loader({params, request}) {
|
||||
const {loadGame, startTimeout} = await import('#state/game.server');
|
||||
const {gameKey} = params;
|
||||
const session = await loadSession(request);
|
||||
if (!session) {
|
||||
throw new Response('', {status: 400});
|
||||
}
|
||||
const game = await loadGame(gameKey);
|
||||
if (!game || !game.players[session.id]) {
|
||||
throw new Response('', {status: 400});
|
||||
}
|
||||
const player = game.players[session.id];
|
||||
const {emitters} = player;
|
||||
// Close long polls since this request was made with JS.
|
||||
player.closeLongPolls();
|
||||
return actionSource(request, (emitter) => {
|
||||
emitters.push(emitter);
|
||||
return () => {
|
||||
emitters.splice(emitters.indexOf(emitter), 1);
|
||||
if (0 === emitters.length) {
|
||||
player.timeout = startTimeout(gameKey, session);
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
142
app/routes/play.$gameKey.messages/route.jsx
Normal file
142
app/routes/play.$gameKey.messages/route.jsx
Normal file
|
@ -0,0 +1,142 @@
|
|||
import {renderToString} from 'react-dom/server';
|
||||
|
||||
import {Presence, State} from '#state/globals';
|
||||
import {loadSession} from '#state/session';
|
||||
import {longPoll} from '#utils/long-poll';
|
||||
|
||||
import PlayersChatMessage from '../play.$gameKey/players/chat/message';
|
||||
import styles from '../play.$gameKey/players/chat/message/index.module.css';
|
||||
import stylesheet from '../play.$gameKey/players/chat/message/index.module.css?raw';
|
||||
|
||||
const compiled = Object.entries(styles)
|
||||
.sort(([l], [r]) => r.length - l.length)
|
||||
.reduce((compiled, [raw, encoded]) => compiled.replaceAll(raw, encoded), stylesheet);
|
||||
|
||||
export async function loader({params, request}) {
|
||||
const {gameKey} = params;
|
||||
const session = await loadSession(request);
|
||||
if (!session) {
|
||||
throw new Response('', {status: 400});
|
||||
}
|
||||
const {loadGame} = await import('#state/game.server');
|
||||
const game = await loadGame(gameKey);
|
||||
if (!game || !game.players[session.id]) {
|
||||
throw new Response('', {status: 400});
|
||||
}
|
||||
return longPoll(request, (send, close) => {
|
||||
let messageOwner = false;
|
||||
let i = -1;
|
||||
function renderMessage(message) {
|
||||
if (!game.players[message.owner]) {
|
||||
return '';
|
||||
}
|
||||
let string = renderToString(
|
||||
<div className={`o${message.owner}`}>
|
||||
<PlayersChatMessage
|
||||
key={message.key}
|
||||
isShort={messageOwner === message.owner}
|
||||
message={message}
|
||||
name={''}
|
||||
/>
|
||||
</div>,
|
||||
);
|
||||
string += `
|
||||
<style>
|
||||
.o${message.owner} .${styles.owner}:before {
|
||||
content: '${game.players[message.owner].name.replaceAll("'", "\\'")}';
|
||||
}
|
||||
</style>
|
||||
`;
|
||||
messageOwner = message.owner;
|
||||
return string;
|
||||
}
|
||||
send(
|
||||
`
|
||||
<!DOCTYPE html><title>.</title>
|
||||
<style>
|
||||
html,body{
|
||||
height: 100%;
|
||||
}
|
||||
body{
|
||||
margin:0;
|
||||
}
|
||||
::-webkit-scrollbar {
|
||||
width: 12px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: #333;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background-color: #777;
|
||||
border-radius: 20px;
|
||||
border: 3px solid #333;
|
||||
}
|
||||
.messages{
|
||||
display:flex;
|
||||
flex-direction:column-reverse;
|
||||
height: 100%;
|
||||
overflow-y:auto;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #777 #333;
|
||||
}
|
||||
.${styles.message}{font-size:0.75em;}
|
||||
${compiled}
|
||||
</style>
|
||||
<div class="messages">
|
||||
${game.messages.map(renderMessage).toReversed().join('')}
|
||||
`
|
||||
// minimize
|
||||
.split('\n').map((s) => s.trim()).join('')
|
||||
);
|
||||
let activePlayers = Object.values(game.players)
|
||||
.filter(({presence}) => presence === Presence.ACTIVE)
|
||||
.length;
|
||||
function onJoined() {
|
||||
activePlayers += 1;
|
||||
if (game.state === State.STARTING && 3 === activePlayers) {
|
||||
close();
|
||||
}
|
||||
}
|
||||
function onMessage({payload}) {
|
||||
// no-js autoscroll
|
||||
send(`<div style="order: ${i--}">${renderMessage(payload)}</div>`);
|
||||
}
|
||||
function onPresence({payload}) {
|
||||
if (Presence.ACTIVE === payload.presence) {
|
||||
activePlayers += 1;
|
||||
if (game.state === State.STARTING && 3 === activePlayers) {
|
||||
close();
|
||||
}
|
||||
}
|
||||
else {
|
||||
activePlayers -= 1;
|
||||
if (game.state === State.STARTING && 2 === activePlayers) {
|
||||
close();
|
||||
}
|
||||
}
|
||||
}
|
||||
function onRename({payload: {name, id}}) {
|
||||
send(`
|
||||
<style>
|
||||
.o${id} .${styles.owner}:before {
|
||||
content: '${name.replaceAll("'", "\\'")}';
|
||||
}
|
||||
</style>
|
||||
`);
|
||||
}
|
||||
game.addActionListener('rename', onRename);
|
||||
game.addActionListener('destroy', close);
|
||||
game.addActionListener('joined', onJoined);
|
||||
game.addActionListener('presence', onPresence);
|
||||
game.addActionListener('message', onMessage);
|
||||
game.addActionListener('state', close);
|
||||
return () => {
|
||||
game.removeActionListener('state', close);
|
||||
game.removeActionListener('destroy', close);
|
||||
game.removeActionListener('joined', onJoined);
|
||||
game.removeActionListener('presence', onPresence);
|
||||
game.removeActionListener('message', onMessage);
|
||||
game.removeActionListener('rename', onRename);
|
||||
};
|
||||
});
|
||||
}
|
19
app/routes/play.$gameKey/bar/chat-button.jsx
Normal file
19
app/routes/play.$gameKey/bar/chat-button.jsx
Normal file
|
@ -0,0 +1,19 @@
|
|||
import styles from './index.module.css';
|
||||
|
||||
export default function ChatButton() {
|
||||
return (
|
||||
<svg
|
||||
className={styles.icon}
|
||||
viewBox="-21 -47 682.66669 682"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="m552.011719-1.332031h-464.023438c-48.515625 0-87.988281 39.464843-87.988281 87.988281v283.972656c0 48.414063 39.300781 87.816406 87.675781 87.988282v128.863281l185.191407-128.863281h279.144531c48.515625 0 87.988281-39.472657 87.988281-87.988282v-283.972656c0-48.523438-39.472656-87.988281-87.988281-87.988281zm50.488281 371.960937c0 27.835938-22.648438 50.488282-50.488281 50.488282h-290.910157l-135.925781 94.585937v-94.585937h-37.1875c-27.839843 0-50.488281-22.652344-50.488281-50.488282v-283.972656c0-27.84375 22.648438-50.488281 50.488281-50.488281h464.023438c27.839843 0 50.488281 22.644531 50.488281 50.488281zm0 0"
|
||||
style={{opacity: 0.5}}
|
||||
/>
|
||||
<path className={styles.lines} d="m171.292969 131.171875h297.414062v37.5h-297.414062zm0 0" />
|
||||
<path className={styles.lines} d="m171.292969 211.171875h297.414062v37.5h-297.414062zm0 0" />
|
||||
<path className={styles.lines} d="m171.292969 291.171875h297.414062v37.5h-297.414062zm0 0" />
|
||||
</svg>
|
||||
);
|
||||
}
|
95
app/routes/play.$gameKey/bar/controls.jsx
Normal file
95
app/routes/play.$gameKey/bar/controls.jsx
Normal file
|
@ -0,0 +1,95 @@
|
|||
import {useBlocker, useFetcher} from '@remix-run/react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {useEffect} from 'react';
|
||||
|
||||
import {useGame, useIsHydrated} from '#hooks';
|
||||
|
||||
import ChatButton from './chat-button';
|
||||
import MuteButton from './mute-button';
|
||||
import styles from './index.module.css';
|
||||
|
||||
function Controls({
|
||||
mutedTuple,
|
||||
chatOpenTuple,
|
||||
unreadTuple,
|
||||
}) {
|
||||
const fetcher = useFetcher({key: 'bar'});
|
||||
const game = useGame();
|
||||
const isHydrated = useIsHydrated();
|
||||
// UX: allow "back" to close the chat.
|
||||
useBlocker(() => {
|
||||
if (!game.destroyed && chatOpenTuple[0]) {
|
||||
chatOpenTuple[1](false);
|
||||
return true;
|
||||
}
|
||||
});
|
||||
useEffect(() => {
|
||||
function onBeforeUnload(event) {
|
||||
if (chatOpenTuple[0]) {
|
||||
chatOpenTuple[1](false);
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
window.addEventListener('beforeunload', onBeforeUnload);
|
||||
return () => {
|
||||
window.removeEventListener('beforeunload', onBeforeUnload);
|
||||
};
|
||||
});
|
||||
return (
|
||||
<div className={styles.controls}>
|
||||
<fetcher.Form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
}}
|
||||
>
|
||||
<button
|
||||
aria-label={mutedTuple[0] ? 'Unmute the game' : 'Mute the game'}
|
||||
className={[
|
||||
styles.mute,
|
||||
(
|
||||
!isHydrated
|
||||
|| 0 === (window.speechSynthesis?.getVoices().length || 0)
|
||||
) && styles.hidden,
|
||||
mutedTuple[0] && styles.muted,
|
||||
].filter(Boolean).join(' ')}
|
||||
onClick={() => {
|
||||
mutedTuple[1](!mutedTuple[0]);
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<MuteButton />
|
||||
</button>
|
||||
<button
|
||||
className={[styles.chat, unreadTuple[0] > 0 && styles.unread].filter(Boolean).join(' ')}
|
||||
name={chatOpenTuple[0] ? '' : 'chat'}
|
||||
onClick={() => {
|
||||
chatOpenTuple[1](!chatOpenTuple[0]);
|
||||
unreadTuple[1](0);
|
||||
}}
|
||||
type="submit"
|
||||
>
|
||||
<ChatButton />
|
||||
<span
|
||||
className={
|
||||
[styles.unread, 0 === unreadTuple[0] && styles.hidden].filter(Boolean).join(' ')
|
||||
}
|
||||
>
|
||||
<span className={styles.rendered}>{unreadTuple[0]}</span>
|
||||
<span className={styles.streaming}></span>
|
||||
</span>
|
||||
|
||||
</button>
|
||||
</fetcher.Form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Controls.displayName = 'Controls';
|
||||
|
||||
Controls.propTypes = {
|
||||
mutedTuple: PropTypes.array,
|
||||
chatOpenTuple: PropTypes.array,
|
||||
unreadTuple: PropTypes.array,
|
||||
}
|
||||
|
||||
export default Controls;
|
47
app/routes/play.$gameKey/bar/index.jsx
Normal file
47
app/routes/play.$gameKey/bar/index.jsx
Normal file
|
@ -0,0 +1,47 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import {useGame} from '#hooks';
|
||||
|
||||
import Controls from './controls';
|
||||
import styles from './index.module.css';
|
||||
import Timeout from './timeout';
|
||||
|
||||
export function unreadUpdateStyle(unread) {
|
||||
return `
|
||||
<style>
|
||||
.${styles.unread}.${styles.hidden} {display: inline !important}
|
||||
.${styles.unread} .${styles.rendered} {display: none}
|
||||
.${styles.streaming}:before {content: '${unread}'}
|
||||
.${styles.chat} svg {
|
||||
animation: ${styles['chat-wiggle']} 7s infinite;
|
||||
}
|
||||
.${styles.chat} svg .${styles.lines} {
|
||||
animation: ${styles.fluoresce} 7s infinite;
|
||||
}
|
||||
</style>
|
||||
`;
|
||||
}
|
||||
|
||||
function Bar(props) {
|
||||
const game = useGame();
|
||||
return (
|
||||
<div className={styles['bar-wrapper']}>
|
||||
<div
|
||||
className={[
|
||||
styles.bar,
|
||||
props.chatOpenTuple[0] && styles.floating,
|
||||
].filter(Boolean).join(' ')}
|
||||
>
|
||||
<Timeout timeout={game.timeout} />
|
||||
<Controls {...props} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Bar.propTypes = {
|
||||
chatOpenTuple: PropTypes.array,
|
||||
};
|
||||
|
||||
Bar.displayName = 'Bar';
|
||||
|
||||
export default Bar;
|
144
app/routes/play.$gameKey/bar/index.module.css
Normal file
144
app/routes/play.$gameKey/bar/index.module.css
Normal file
|
@ -0,0 +1,144 @@
|
|||
.bar-wrapper {
|
||||
height: 6rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.bar {
|
||||
align-items: center;
|
||||
background-color: #111;
|
||||
display: flex;
|
||||
height: 100%;
|
||||
left: 0;
|
||||
justify-content: space-between;
|
||||
text-align: center;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
|
||||
button.chat, button.mute {
|
||||
&:focus {
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
button.chat {
|
||||
position: relative;
|
||||
@media(min-width: 80em) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
svg .lines {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
&.unread svg {
|
||||
animation: chat-wiggle 7s infinite;
|
||||
& .lines {
|
||||
animation: fluoresce 7s infinite;
|
||||
}
|
||||
}
|
||||
|
||||
.unread {
|
||||
font-family: 'Lucida Sans', 'Lucida Sans Regular', 'Lucida Grande', 'Lucida Sans Unicode', Geneva, Verdana, sans-serif;
|
||||
position: absolute;
|
||||
color: white;
|
||||
border-radius: 0.5rem;
|
||||
background-color: #880000;
|
||||
font-size: 100%;
|
||||
padding: 0.25rem 0.25rem;
|
||||
right: 1rem;
|
||||
top: 2.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
button.mute {
|
||||
transition: opacity 0.5s;
|
||||
svg {
|
||||
opacity: 0.7;
|
||||
}
|
||||
&.muted {
|
||||
svg {
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes chat-wiggle {
|
||||
from { transform: rotate(25deg) scale(1); }
|
||||
2% { transform: rotate(-12deg) scale(1.15); }
|
||||
4% { transform: rotate(12deg); }
|
||||
4.2% { transform: rotate(0deg); }
|
||||
4.4% { transform: rotate(6deg); }
|
||||
4.6% { transform: rotate(-3deg) scale(1); }
|
||||
4.7% { transform: rotate(0deg) scale(1); }
|
||||
4.75% { transform: rotate(1.5deg) scale(1); }
|
||||
4.8% { transform: rotate(0deg) scale(1); }
|
||||
}
|
||||
|
||||
@keyframes fluoresce {
|
||||
from { opacity: 0.5; }
|
||||
4.8% { opacity: 1.0; }
|
||||
to { opacity: 0.5; }
|
||||
}
|
||||
|
||||
.mute .line {
|
||||
transition: opacity 0.4s;
|
||||
}
|
||||
|
||||
.mute.muted .line {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.mute:not(.muted) .line {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.mute path, .mute polygon {
|
||||
fill: #999999;
|
||||
}
|
||||
|
||||
.mute .wave {
|
||||
transition: fill 0.4s;
|
||||
}
|
||||
|
||||
.mute rect, .mute:not(.muted) .wave {
|
||||
fill: #ffffff;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.controls {
|
||||
line-height: 0;
|
||||
form {
|
||||
display: inline-block;
|
||||
}
|
||||
button {
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
|
||||
.icon {
|
||||
align-items: center;
|
||||
background-color: #111;
|
||||
display: flex;
|
||||
fill: #fff;
|
||||
justify-content: center;
|
||||
margin: 1rem;
|
||||
height: 3rem;
|
||||
width: 3rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.iframe {
|
||||
border: 0;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
width: 10em;
|
||||
}
|
||||
|
||||
.rendered, .streaming {
|
||||
--nothing: 0;
|
||||
}
|
46
app/routes/play.$gameKey/bar/mute-button.jsx
Normal file
46
app/routes/play.$gameKey/bar/mute-button.jsx
Normal file
|
@ -0,0 +1,46 @@
|
|||
import styles from './index.module.css';
|
||||
|
||||
export default function MuteButton() {
|
||||
return (
|
||||
<svg
|
||||
className={styles.icon}
|
||||
version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 500.184 500.184"
|
||||
>
|
||||
<path
|
||||
d="M114.215,178.215H34.462c-10.831,0-20.677,8.861-20.677,20.677v99.446c0,10.831,8.862,20.677,20.677,20.677h79.754
|
||||
c11.815,0,20.677-9.846,20.677-20.677v-99.446C134.892,188.061,126.031,178.215,114.215,178.215z M115.2,297.354
|
||||
c0,0,0,0.985-0.985,0.985H34.462c0,0-0.985,0-0.985-0.985v-99.446c0,0,0-0.985,0.985-0.985h79.754c0,0,0.985,0,0.985,0.985
|
||||
V297.354z"
|
||||
/>
|
||||
<polygon
|
||||
points="114.215,183.138 128,196.923 278.646,47.261 278.646,452.923 128,302.277 114.215,316.062 298.338,500.184
|
||||
298.338,0"
|
||||
/>
|
||||
<path
|
||||
className={styles.wave}
|
||||
d="M288.492,176.246v19.692c31.508,0,56.123,23.631,56.123,53.169s-25.6,53.169-56.123,53.169v19.692
|
||||
c42.339,0,75.815-32.492,75.815-72.861S329.846,176.246,288.492,176.246z"
|
||||
/>
|
||||
<path
|
||||
className={styles.wave}
|
||||
d="M355.446,139.815l-8.862,17.723c35.446,17.723,57.108,52.185,57.108,90.585c0,36.431-18.708,68.923-51.2,87.631
|
||||
l9.846,16.739c37.415-21.661,60.062-61.046,61.046-104.369C423.384,202.831,397.784,160.492,355.446,139.815z"
|
||||
/>
|
||||
<path
|
||||
className={styles.wave}
|
||||
d="M397.785,93.538l-9.846,16.739c49.231,29.538,78.769,79.754,78.769,135.877c0,56.123-29.538,107.323-79.754,136.862
|
||||
l9.846,16.739c56.123-32.492,89.6-90.585,89.6-152.615C486.4,184.123,452.923,127.015,397.785,93.538z"
|
||||
/>
|
||||
<rect
|
||||
className={styles.line}
|
||||
x="-65.352"
|
||||
y="242.48"
|
||||
transform="matrix(0.7029 -0.7112 0.7112 0.7029 -109.9687 241.3525)"
|
||||
width="598.608"
|
||||
height="19.691"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
56
app/routes/play.$gameKey/bar/timeout/index.jsx
Normal file
56
app/routes/play.$gameKey/bar/timeout/index.jsx
Normal file
|
@ -0,0 +1,56 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import {
|
||||
useEffect,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import styles from './index.module.css';
|
||||
import Number from './number';
|
||||
|
||||
function Timeout({timeout}) {
|
||||
const [counter, setCounter] = useState(timeout);
|
||||
useEffect(() => {
|
||||
setCounter(timeout);
|
||||
}, [timeout]);
|
||||
useEffect(() => {
|
||||
let last = Date.now();
|
||||
const ms = 77;
|
||||
const handle = setInterval(() => {
|
||||
const now = Date.now();
|
||||
const elapsed = (now - last) / 1000;
|
||||
last = now;
|
||||
setCounter((counter) => counter - elapsed);
|
||||
}, ms);
|
||||
return () => {
|
||||
clearInterval(handle);
|
||||
};
|
||||
}, []);
|
||||
let formatted;
|
||||
if (counter > 10000) {
|
||||
formatted = '';
|
||||
}
|
||||
else if (counter <= 0) {
|
||||
formatted = '0';
|
||||
}
|
||||
else if (counter >= 10) {
|
||||
formatted = `${Math.floor(counter)}`;
|
||||
}
|
||||
else {
|
||||
formatted = counter.toFixed(2);
|
||||
}
|
||||
return (
|
||||
<div className={styles.timeout}>
|
||||
<Number value={formatted} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Timeout.defaultProps = {};
|
||||
|
||||
Timeout.propTypes = {
|
||||
timeout: PropTypes.number.isRequired,
|
||||
};
|
||||
|
||||
Timeout.displayName = 'Timeout';
|
||||
|
||||
export default Timeout;
|
6
app/routes/play.$gameKey/bar/timeout/index.module.css
Normal file
6
app/routes/play.$gameKey/bar/timeout/index.module.css
Normal file
|
@ -0,0 +1,6 @@
|
|||
.timeout {
|
||||
color: #fff;
|
||||
font-family: Caladea, 'Times New Roman', Times, serif;
|
||||
font-size: 3rem;
|
||||
margin: 0 0 0 1.5rem;
|
||||
}
|
21
app/routes/play.$gameKey/bar/timeout/number-initializer.jsx
Normal file
21
app/routes/play.$gameKey/bar/timeout/number-initializer.jsx
Normal file
|
@ -0,0 +1,21 @@
|
|||
import styles from './number.module.css';
|
||||
|
||||
export default function Initializer() {
|
||||
return (
|
||||
<script
|
||||
// so dramatic...
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
{
|
||||
const styleSheet = document.createElement('style');
|
||||
styleSheet.innerText = [
|
||||
'.${styles.rendered} {display: inline !important}',
|
||||
'.${styles.streamed} {display: none !important}',
|
||||
].join('');
|
||||
document.head.appendChild(styleSheet);
|
||||
}
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
50
app/routes/play.$gameKey/bar/timeout/number.jsx
Normal file
50
app/routes/play.$gameKey/bar/timeout/number.jsx
Normal file
|
@ -0,0 +1,50 @@
|
|||
import PropTypes from 'prop-types';
|
||||
|
||||
import styles from './number.module.css';
|
||||
|
||||
export function numberUpdateStyle(c, m) {
|
||||
return `
|
||||
<style>
|
||||
.${styles.rendered} {display:none}
|
||||
.${styles.characteristic} .${styles.streamed}:before {
|
||||
content: '${Math.floor(c)}';
|
||||
}
|
||||
${
|
||||
m
|
||||
? `
|
||||
.${styles.mantissa} .${styles.streamed}:before {
|
||||
content: '.${m.slice(0, 2)}';
|
||||
}
|
||||
`
|
||||
: ''
|
||||
}
|
||||
</style>
|
||||
`;
|
||||
}
|
||||
|
||||
export default function Number(props) {
|
||||
const {value} = props;
|
||||
const [c, m] = value.split('.');
|
||||
return (
|
||||
<div className={styles.number}>
|
||||
<span className={styles.characteristic}>
|
||||
<span className={styles.rendered}>{c}</span>
|
||||
<span className={styles.streamed}></span>
|
||||
</span>
|
||||
<span className={styles.mantissa}>
|
||||
<span className={styles.rendered}>
|
||||
{
|
||||
m && (
|
||||
<>.{m.slice(0, 2)}</>
|
||||
)
|
||||
}
|
||||
</span>
|
||||
<span className={styles.streamed}></span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Number.propTypes = {
|
||||
value: PropTypes.string.isRequired,
|
||||
};
|
7
app/routes/play.$gameKey/bar/timeout/number.module.css
Normal file
7
app/routes/play.$gameKey/bar/timeout/number.module.css
Normal file
|
@ -0,0 +1,7 @@
|
|||
.characteristic, .rendered, .streamed {
|
||||
--nothing: 0;
|
||||
}
|
||||
|
||||
.mantissa {
|
||||
font-size: 0.4em;
|
||||
}
|
49
app/routes/play.$gameKey/index.module.css
Normal file
49
app/routes/play.$gameKey/index.module.css
Normal file
|
@ -0,0 +1,49 @@
|
|||
.play {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.black-and-white {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100vw;
|
||||
@media(min-width: 80em) {
|
||||
width: 60vw;
|
||||
}
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.black-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 50%;
|
||||
}
|
||||
|
||||
.black {
|
||||
background-color: #151515;
|
||||
color: #b1b1b1;
|
||||
display: flex;
|
||||
flex-basis: calc(100% - 6rem);
|
||||
overflow: hidden;
|
||||
padding: 4vh 4vw;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.white-wrapper {
|
||||
height: 50%;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.white {
|
||||
background-color: #dfdfdf;
|
||||
color: #151515;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.no-cookie {
|
||||
padding: 4vh 8vw;
|
||||
}
|
53
app/routes/play.$gameKey/players/chat/form.jsx
Normal file
53
app/routes/play.$gameKey/players/chat/form.jsx
Normal file
|
@ -0,0 +1,53 @@
|
|||
import {useState} from 'react';
|
||||
import {useFetcher, useParams} from '@remix-run/react';
|
||||
|
||||
import {useGame, useIsHydrated, useSession} from '#hooks';
|
||||
|
||||
import styles from './index.module.css';
|
||||
|
||||
export default function Form() {
|
||||
const {gameKey} = useParams();
|
||||
const game = useGame();
|
||||
const isHydrated = useIsHydrated();
|
||||
const session = useSession();
|
||||
const [text, setText] = useState('');
|
||||
const fetcher = useFetcher();
|
||||
return (
|
||||
<fetcher.Form
|
||||
action={`/play/${gameKey}`}
|
||||
className={styles.form}
|
||||
method="post"
|
||||
onSubmit={(event) => {
|
||||
const formData = new FormData(event.target);
|
||||
const key = formData.get('key');
|
||||
game.messages.push({
|
||||
key,
|
||||
owner: session.id,
|
||||
text: formData.get('text'),
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
setText('');
|
||||
}}
|
||||
>
|
||||
<input
|
||||
aria-label="Write a chat message"
|
||||
autoComplete="off"
|
||||
// Doesn't work in iframe..?
|
||||
autoFocus // eslint-disable-line jsx-a11y/no-autofocus
|
||||
name="message"
|
||||
type="text"
|
||||
maxLength="1024"
|
||||
onChange={(event) => {
|
||||
setText(event.target.value);
|
||||
}}
|
||||
tabIndex={0}
|
||||
value={text}
|
||||
/>
|
||||
{!isHydrated && (
|
||||
<input name="chat" type="hidden" />
|
||||
)}
|
||||
<input name="key" type="hidden" value={Math.random()} />
|
||||
<input className={styles.hidden} name="action" type="submit" value="message" />
|
||||
</fetcher.Form>
|
||||
);
|
||||
}
|
62
app/routes/play.$gameKey/players/chat/index.jsx
Normal file
62
app/routes/play.$gameKey/players/chat/index.jsx
Normal file
|
@ -0,0 +1,62 @@
|
|||
import {useParams} from '@remix-run/react';
|
||||
import ScrollableFeed from 'react-scrollable-feed';
|
||||
|
||||
import {useGame, useIsHydrated} from '#hooks';
|
||||
|
||||
import styles from './index.module.css';
|
||||
import Form from './form';
|
||||
import PlayersChatMessage from './message';
|
||||
|
||||
export default function PlayersChatSpace() {
|
||||
const game = useGame();
|
||||
const isHydrated = useIsHydrated();
|
||||
const {gameKey} = useParams();
|
||||
let messageOwner = false;
|
||||
return (
|
||||
<div className={styles.chat}>
|
||||
<ScrollableFeed>
|
||||
<div className={styles.stretch} />
|
||||
{
|
||||
isHydrated
|
||||
? (
|
||||
game.messages
|
||||
.filter((message) => game.players[message.owner])
|
||||
.sort((l, r) => l.timestamp < r.timestamp ? -1 : 1)
|
||||
.map((message) => {
|
||||
const $message = (
|
||||
<PlayersChatMessage
|
||||
key={message.key}
|
||||
isShort={messageOwner === message.owner}
|
||||
message={message}
|
||||
name={game.players[message.owner].name}
|
||||
/>
|
||||
);
|
||||
messageOwner = message.owner;
|
||||
return $message;
|
||||
})
|
||||
)
|
||||
: (
|
||||
<iframe
|
||||
className={styles['messages-iframe']}
|
||||
src={`/play/${gameKey}/messages`}
|
||||
title="Chat messages"
|
||||
/>
|
||||
)
|
||||
}
|
||||
</ScrollableFeed>
|
||||
{
|
||||
isHydrated
|
||||
? (
|
||||
<Form />
|
||||
)
|
||||
: (
|
||||
<iframe
|
||||
className={styles['form-iframe']}
|
||||
src={`/play/${gameKey}/chat-form`}
|
||||
title="Chat form"
|
||||
/>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
49
app/routes/play.$gameKey/players/chat/index.module.css
Normal file
49
app/routes/play.$gameKey/players/chat/index.module.css
Normal file
|
@ -0,0 +1,49 @@
|
|||
.chat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-size: 0.75rem;
|
||||
height: 100%;
|
||||
margin-top: auto;
|
||||
overflow: hidden;
|
||||
> div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.form {
|
||||
background-color: #222;
|
||||
}
|
||||
|
||||
.form input[type="text"] {
|
||||
background-color: #333;
|
||||
border: none;
|
||||
border-image-width: 0;
|
||||
border-radius: 1rem;
|
||||
color: #aaa;
|
||||
font-family: 'Lucida Sans', 'Lucida Sans Regular', 'Lucida Grande', 'Lucida Sans Unicode', Geneva, Verdana, sans-serif;
|
||||
font-size: 1.5rem;
|
||||
margin: 1rem;
|
||||
padding: 0.75rem;
|
||||
resize: none;
|
||||
width: calc(100% - 2rem);
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.form-iframe {
|
||||
border: none;
|
||||
height: calc(7em + 3px);
|
||||
}
|
||||
|
||||
.messages-iframe {
|
||||
border: none;
|
||||
height: calc(100% - 0.5rem);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.stretch {
|
||||
flex-grow: 1;
|
||||
}
|
43
app/routes/play.$gameKey/players/chat/message/index.jsx
Normal file
43
app/routes/play.$gameKey/players/chat/message/index.jsx
Normal file
|
@ -0,0 +1,43 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
|
||||
import styles from './index.module.css';
|
||||
|
||||
export default function PlayersChatMessageSpace(props) {
|
||||
const {message: {text, timestamp}, isShort, name} = props;
|
||||
const dtf = new Intl.DateTimeFormat(undefined, {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
const date = new Date(timestamp);
|
||||
return (
|
||||
<div
|
||||
className={[styles.message, isShort && styles.short].filter(Boolean).join(' ')}
|
||||
>
|
||||
{
|
||||
!isShort && (
|
||||
<header>
|
||||
<div className={styles.owner}>{name}</div>
|
||||
<div className={styles.time}>{dtf.format(date)}</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
<div className={styles.text}>
|
||||
<div className={styles.markdown}>
|
||||
<ReactMarkdown>{text}</ReactMarkdown>
|
||||
</div>
|
||||
{isShort && <div className={styles.time}>{dtf.format(date)}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
PlayersChatMessageSpace.propTypes = {
|
||||
isShort: PropTypes.bool.isRequired,
|
||||
message: PropTypes.shape({
|
||||
text: PropTypes.string,
|
||||
timestamp: PropTypes.number,
|
||||
owner: PropTypes.number,
|
||||
}).isRequired,
|
||||
name: PropTypes.string,
|
||||
};
|
|
@ -0,0 +1,84 @@
|
|||
.message {
|
||||
color: #aaa;
|
||||
padding: 0.125rem 1.75rem;
|
||||
}
|
||||
|
||||
.message, .message * {
|
||||
font-family: 'Lucida Sans', 'Lucida Sans Regular', 'Lucida Grande', 'Lucida Sans Unicode', Geneva, Verdana, sans-serif;
|
||||
}
|
||||
|
||||
.message:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.message.short {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.message.short .time {
|
||||
display: inline;
|
||||
font-size: 75%;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.message.short:hover .time {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.message.short header .owner {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.message:hover {
|
||||
background-color: #1c1c1c;
|
||||
}
|
||||
|
||||
.message header {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.message .owner {
|
||||
color: #d68030aa;
|
||||
font-family: Caladea, 'Times New Roman', Times, serif;
|
||||
font-weight: bold;
|
||||
margin-bottom: 0.125em;
|
||||
font-size: 200%;
|
||||
}
|
||||
|
||||
.message .time {
|
||||
color: #666;
|
||||
font-size: 150%;
|
||||
margin-left: 0.5em;
|
||||
}
|
||||
|
||||
.message header .time {
|
||||
line-height: 1.75;
|
||||
}
|
||||
|
||||
.message .text {
|
||||
font-size: 200%;
|
||||
}
|
||||
|
||||
.message .text .markdown * {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.message .text .markdown {
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
line-height: 1em;
|
||||
}
|
||||
}
|
||||
|
||||
.message .text .markdown {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.message .text .markdown :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.message .text .markdown :last-child {
|
||||
display: inline;
|
||||
}
|
29
app/routes/play.$gameKey/players/index.jsx
Normal file
29
app/routes/play.$gameKey/players/index.jsx
Normal file
|
@ -0,0 +1,29 @@
|
|||
import PropTypes from 'prop-types';
|
||||
|
||||
import PlayersChatSpace from './chat';
|
||||
import List from './list';
|
||||
import styles from './index.module.css';
|
||||
|
||||
function PlayersSpace({isOpen}) {
|
||||
return (
|
||||
<div
|
||||
className={[
|
||||
styles['players-space-container'],
|
||||
isOpen && styles.opened,
|
||||
].filter(Boolean).join(' ')}
|
||||
>
|
||||
<div className={styles['players-space']}>
|
||||
<div className={styles['players-list']}>
|
||||
<List />
|
||||
</div>
|
||||
<PlayersChatSpace />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
PlayersSpace.propTypes = {
|
||||
isOpen: PropTypes.bool.isRequired,
|
||||
};
|
||||
|
||||
export default PlayersSpace;
|
52
app/routes/play.$gameKey/players/index.module.css
Normal file
52
app/routes/play.$gameKey/players/index.module.css
Normal file
|
@ -0,0 +1,52 @@
|
|||
.players-space-container {
|
||||
background-color: #222222;
|
||||
font-family: Caladea, 'Times New Roman', Times, serif;
|
||||
font-size: 3vw;
|
||||
@media(min-width: 50em) {
|
||||
font-size: 2vw;
|
||||
}
|
||||
height: 100%;
|
||||
max-width: 100%;
|
||||
opacity: 0.95;
|
||||
position: absolute;
|
||||
left: -100vw;
|
||||
transition: left 0.1s;
|
||||
top: calc(6rem - 1px);
|
||||
width: 100%;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.players-space-container.opened {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
@media(min-width: 80em) {
|
||||
.players-space-container {
|
||||
font-size: 1vw;
|
||||
left: auto;
|
||||
right: 0;
|
||||
top: 0;
|
||||
width: 40vw;
|
||||
|
||||
&.opened {
|
||||
left: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.players-space {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100vh - 6rem);
|
||||
@media(min-width: 80em) {
|
||||
height: 100vh;
|
||||
}
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media(min-width: 64em) {
|
||||
.players-space {
|
||||
float: left;
|
||||
position: static;
|
||||
}
|
||||
}
|
219
app/routes/play.$gameKey/players/list/index.jsx
Normal file
219
app/routes/play.$gameKey/players/list/index.jsx
Normal file
|
@ -0,0 +1,219 @@
|
|||
import {
|
||||
Form,
|
||||
useFetcher,
|
||||
useSearchParams,
|
||||
} from "@remix-run/react";
|
||||
import {useEffect, useState} from 'react';
|
||||
|
||||
import {Presence} from '#state/globals';
|
||||
import {useGame, useIsHydrated, useSession} from '#hooks';
|
||||
|
||||
import styles from './index.module.css';
|
||||
|
||||
export function playerJoinedUpdateStyle(index, {name, score}) {
|
||||
return `
|
||||
<style>
|
||||
.${styles.player}:nth-child(${index + 1}) {
|
||||
display: inline-block !important;
|
||||
order: 10;
|
||||
}
|
||||
.${styles.player}:nth-child(${index + 1})
|
||||
.${styles.name} {
|
||||
opacity: 1;
|
||||
text-decoration: none;
|
||||
}
|
||||
.${styles.player}:nth-child(${index + 1})
|
||||
.${styles.name}
|
||||
.${styles.streaming}:before {
|
||||
content: '${name.replaceAll("'", "\\'")}';
|
||||
}
|
||||
.${styles.player}:nth-child(${index + 1})
|
||||
.${styles.score}
|
||||
.${styles.number}:before {
|
||||
content: '${score}';
|
||||
}
|
||||
</style>
|
||||
`;
|
||||
}
|
||||
|
||||
export function playerNameUpdateStyle(index, name) {
|
||||
return `
|
||||
<style>
|
||||
.${styles.player}:nth-child(${index + 1})
|
||||
.${styles.name}
|
||||
.${styles.rendered} {
|
||||
display:none;
|
||||
}
|
||||
.${styles.player}:nth-child(${index + 1})
|
||||
.${styles.name}
|
||||
.${styles.streaming}:before {
|
||||
content: '${name.replaceAll("'", "\\'")}';
|
||||
}
|
||||
</style>
|
||||
`;
|
||||
}
|
||||
|
||||
export function playerPresenceUpdateStyle(presence, index) {
|
||||
return `
|
||||
<style>
|
||||
.${styles.player}:nth-child(${index + 1})
|
||||
.${styles.name} {
|
||||
opacity: ${Presence.ACTIVE === presence ? '1' : '0.3'};
|
||||
text-decoration: ${Presence.ACTIVE === presence ? 'none' : 'line-through'};
|
||||
}
|
||||
</style>
|
||||
`;
|
||||
}
|
||||
|
||||
export function playerRemoveUpdateStyle(index) {
|
||||
return `
|
||||
<style>
|
||||
.${styles.player}:nth-child(${index + 1}) {
|
||||
display: none !important;
|
||||
}
|
||||
</style>
|
||||
`;
|
||||
}
|
||||
|
||||
export const sortPlayers = (game) => (l, r) => {
|
||||
if (+l[0] === game.czar) {
|
||||
return -1;
|
||||
}
|
||||
if (+r[0] === game.czar) {
|
||||
return 1;
|
||||
}
|
||||
if (
|
||||
Presence.INACTIVE === l[1].presence
|
||||
&& Presence.ACTIVE === r[1].presence
|
||||
) {
|
||||
return 1;
|
||||
}
|
||||
if (
|
||||
Presence.INACTIVE === r[1].presence
|
||||
&& Presence.ACTIVE === l[1].presence
|
||||
) {
|
||||
return -1;
|
||||
}
|
||||
return r[1].score - l[1].score;
|
||||
};
|
||||
|
||||
export default function List() {
|
||||
const game = useGame();
|
||||
const session = useSession();
|
||||
const isHydrated = useIsHydrated();
|
||||
const [searchParams] = useSearchParams();
|
||||
const [isEditing, setIsEditing] = useState(searchParams.has('edit-name'));
|
||||
const fetcher = useFetcher();
|
||||
useEffect(() => {
|
||||
if ('submitting' === fetcher.state) {
|
||||
setIsEditing(false);
|
||||
}
|
||||
}, [fetcher.state]);
|
||||
const players = Object.entries(game.players);
|
||||
return (
|
||||
<div className={styles['players-list']}>
|
||||
{
|
||||
players
|
||||
.sort(sortPlayers(game))
|
||||
.concat(
|
||||
isHydrated
|
||||
? []
|
||||
: (
|
||||
Array.from({length: 9 - players.length})
|
||||
.map((e, i) => [-10 - i, {name: '', score: ''}])
|
||||
)
|
||||
)
|
||||
.map(([id, player]) => {
|
||||
const isCzar = game.czar === +id;
|
||||
const isOwner = game.owner === +id;
|
||||
const isSelf = session.id === +id;
|
||||
return (
|
||||
<div
|
||||
key={+id}
|
||||
className={[
|
||||
styles.player,
|
||||
Presence.INACTIVE === player.presence && styles.blurred,
|
||||
'' === player.name && styles.hidden,
|
||||
isCzar && styles.czar,
|
||||
isOwner && styles.owner,
|
||||
isSelf && styles.self,
|
||||
].filter(Boolean).join(' ')}
|
||||
>
|
||||
{
|
||||
isSelf
|
||||
? (
|
||||
isEditing
|
||||
? (
|
||||
<fetcher.Form method="put">
|
||||
<input
|
||||
aria-label="Edit your name"
|
||||
// It's fine; being dumped here should be expected.
|
||||
autoFocus // eslint-disable-line jsx-a11y/no-autofocus
|
||||
type="text"
|
||||
maxLength="24"
|
||||
name="name"
|
||||
onBlur={() => {
|
||||
setIsEditing(false);
|
||||
}}
|
||||
onKeyDown={({key}) => {
|
||||
if ('Escape' === key) {
|
||||
setIsEditing(false);
|
||||
}
|
||||
}}
|
||||
placeholder={player.name}
|
||||
ref={(input) => input && input.focus()}
|
||||
/>
|
||||
{
|
||||
isHydrated
|
||||
? (
|
||||
<button
|
||||
className={styles.hydrated}
|
||||
name="action"
|
||||
type="submit"
|
||||
value="rename"
|
||||
>
|
||||
Confirm
|
||||
</button>
|
||||
)
|
||||
: (
|
||||
<>
|
||||
<button name="action" type="submit" value="rename">Confirm</button>
|
||||
<button name="cancel" type="submit">Cancel</button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
</fetcher.Form>
|
||||
)
|
||||
: (
|
||||
<Form
|
||||
onSubmit={(event) => {
|
||||
setIsEditing(true);
|
||||
event.preventDefault();
|
||||
}}
|
||||
>
|
||||
<input name="chat" type="hidden" />
|
||||
<input name="edit-name" type="hidden" />
|
||||
<button className={styles.name} type="submit">
|
||||
{/* Optimism. */}
|
||||
{fetcher.formData?.get('name') || player.name}
|
||||
</button>
|
||||
</Form>
|
||||
)
|
||||
)
|
||||
: (
|
||||
<span className={styles.name}>
|
||||
<span className={styles.rendered}>{player.name}</span>
|
||||
<span className={styles.streaming}></span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
<span className={styles.score}>[{
|
||||
<span className={styles.number}>{player.score}</span>
|
||||
}]</span>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
157
app/routes/play.$gameKey/players/list/index.module.css
Normal file
157
app/routes/play.$gameKey/players/list/index.module.css
Normal file
|
@ -0,0 +1,157 @@
|
|||
.players-list {
|
||||
background-color: #262626;
|
||||
box-shadow: 0 4px 4px -6px black;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
padding: 0.5rem 0.5rem 0 0.5rem;
|
||||
}
|
||||
|
||||
.player {
|
||||
background-color: #171717;
|
||||
border-radius: 5px;
|
||||
color: hsl(0, 0%, 67%);
|
||||
display: inline-block;
|
||||
font-family: var(--default-font);
|
||||
font-size: 150%;
|
||||
margin: 0 0.5rem 0.5rem 0;
|
||||
padding: 0.5em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.player form {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.player button {
|
||||
background-color: inherit;
|
||||
border: none;
|
||||
color: inherit;
|
||||
font-size: inherit;
|
||||
|
||||
&[name="cancel"] {
|
||||
font-size: 0.7em;
|
||||
margin: 0 6px 0 -6px;
|
||||
opacity: 0.4;
|
||||
&:hover {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
&[name="action"] {
|
||||
color: #d68030;
|
||||
font-family: Strike;
|
||||
margin-left: -6px;
|
||||
&:hover {
|
||||
color: #d69d67;
|
||||
}
|
||||
}
|
||||
&.hydrated {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.player.czar {
|
||||
background-color: #b1b1b1;
|
||||
color: #1c1c1c;
|
||||
display: block;
|
||||
font-size: 200%;
|
||||
margin: 0 0 0.5rem 0;
|
||||
top: 1em;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.player.czar .spinny {
|
||||
animation: spinny 5s infinite;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
@keyframes spinny {
|
||||
from { transform: rotate(0deg); }
|
||||
25% { transform: rotate(0deg); }
|
||||
50% { transform: rotate(180deg); }
|
||||
75% { transform: rotate(180deg); }
|
||||
to { transform: rotate(0deg); }
|
||||
}
|
||||
|
||||
.player.owner::before {
|
||||
content: '👑 ';
|
||||
}
|
||||
|
||||
.player.czar .score {
|
||||
color: #888;
|
||||
font-size: 80%;
|
||||
.number {
|
||||
text-shadow:
|
||||
-1px 0px 0px #444,
|
||||
1px 0px 0px #444,
|
||||
0px -1px 0px #444,
|
||||
0px 1px 0px #444
|
||||
;
|
||||
}
|
||||
}
|
||||
|
||||
.player .name {
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.player.czar .name {
|
||||
&::after {
|
||||
color: #000;
|
||||
content: ' (terrible)';
|
||||
font-size: 80%;
|
||||
}
|
||||
}
|
||||
|
||||
.player.self .name {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.player.self .score {
|
||||
&::before {
|
||||
color: #fff;
|
||||
content: '(you) ';
|
||||
font-size: 80%;
|
||||
margin-left: -6px;
|
||||
}
|
||||
}
|
||||
|
||||
.player.self.czar .name {
|
||||
&::after {
|
||||
color: #000;
|
||||
}
|
||||
}
|
||||
|
||||
.player.blurred .name {
|
||||
opacity: 0.3;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.player input {
|
||||
background: #333;
|
||||
color: #ffffff;
|
||||
margin-right: 6px;
|
||||
padding-block: 1px;
|
||||
padding-inline: 6px;
|
||||
font-family: var(--default-font);
|
||||
font-size: 100%;
|
||||
}
|
||||
|
||||
.player .score {
|
||||
color: #555;
|
||||
font-size: 100%;
|
||||
.number {
|
||||
color: #d68030;
|
||||
font-style: italic;
|
||||
font-family: Caladea, 'Times New Roman', Times, serif;
|
||||
}
|
||||
}
|
||||
|
||||
.rendered, .streaming {
|
||||
--nothing: 0;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
230
app/routes/play.$gameKey/route.jsx
Normal file
230
app/routes/play.$gameKey/route.jsx
Normal file
|
@ -0,0 +1,230 @@
|
|||
import {
|
||||
json,
|
||||
redirect,
|
||||
useLoaderData,
|
||||
useNavigate,
|
||||
useParams,
|
||||
useSearchParams,
|
||||
} from '@remix-run/react';
|
||||
import {useEffect, useState} from 'react';
|
||||
|
||||
import FluidText from '#fluid-text';
|
||||
import {GameContext, SelectionContext, SessionContext} from '#hooks/context';
|
||||
import {Game} from '#state/game';
|
||||
import {State} from '#state/globals';
|
||||
import {juggleSession, loadSession} from '#state/session';
|
||||
|
||||
import '../../fonts/index.css';
|
||||
import Bar from './bar';
|
||||
import styles from './index.module.css';
|
||||
import Players from './players';
|
||||
|
||||
export const meta = ({params: {gameKey}}) => {
|
||||
return [{title: `${gameKey} | Doing Terrible`}];
|
||||
};
|
||||
|
||||
// :)
|
||||
export function shouldRevalidate() {
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function action({params, request}) {
|
||||
const {loadGame} = await import('#state/game.server');
|
||||
const formData = await request.formData();
|
||||
const {gameKey} = params;
|
||||
if (formData.has('cancel')) {
|
||||
return redirect(`/play/${gameKey}?chat`);
|
||||
}
|
||||
const game = await loadGame(gameKey);
|
||||
if (!game) {
|
||||
throw new Response('', {status: 400});
|
||||
}
|
||||
const session = await loadSession(request);
|
||||
if (!session.id || !game.players[session.id]) {
|
||||
throw new Response('', {status: 400});
|
||||
}
|
||||
game.handleAction(formData, session);
|
||||
// no-js chat message
|
||||
if (formData.has('chat')) {
|
||||
return redirect(`/play/${gameKey}/chat-form`);
|
||||
}
|
||||
else {
|
||||
return redirect(`/play/${gameKey}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function loader({params, request}) {
|
||||
const {gameKey} = params;
|
||||
const {loadGame} = await import('#state/game.server');
|
||||
const game = await loadGame(gameKey);
|
||||
if (!game) {
|
||||
throw redirect('/');
|
||||
}
|
||||
const session = await juggleSession(request);
|
||||
if (
|
||||
9 === Object.keys(game.players).length
|
||||
&& !game.players[session.id]
|
||||
) {
|
||||
throw redirect('/join?full');
|
||||
}
|
||||
return json({
|
||||
game: game.loaderData(session),
|
||||
session,
|
||||
});
|
||||
}
|
||||
|
||||
// import.meta.glob
|
||||
import * as starting from './status/starting';
|
||||
import * as answering from './status/answering';
|
||||
import * as awarding from './status/awarding';
|
||||
import * as awarded from './status/awarded';
|
||||
import * as finished from './status/finished';
|
||||
import * as paused from './status/paused';
|
||||
const Components = {
|
||||
[State.STARTING]: starting,
|
||||
[State.ANSWERING]: answering,
|
||||
[State.AWARDING]: awarding,
|
||||
[State.AWARDED]: awarded,
|
||||
[State.FINISHED]: finished,
|
||||
[State.PAUSED]: paused,
|
||||
};
|
||||
|
||||
const NeedsCookie = {
|
||||
Black: () => <FluidText><div>Need cookies enabled to play!</div></FluidText>,
|
||||
White: () => <FluidText><div className={styles['no-cookie']}>Sorry!</div></FluidText>,
|
||||
};
|
||||
|
||||
function Play() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const mutedTuple = useState(true);
|
||||
const [isMuted] = mutedTuple;
|
||||
const unreadTuple = useState(0);
|
||||
const navigate = useNavigate();
|
||||
const {gameKey} = useParams();
|
||||
const {
|
||||
game,
|
||||
session,
|
||||
} = useLoaderData();
|
||||
const chatOpenTuple = useState(searchParams.has('chat'));
|
||||
const selectionTuple = useState(
|
||||
game.players[session.id]?.answer || searchParams.getAll('selection').map((i) => +i) || []
|
||||
);
|
||||
const setSelection = selectionTuple[1];
|
||||
const [, forceRender] = useState();
|
||||
const [message, setMessage] = useState('');
|
||||
useEffect(() => {
|
||||
switch (game.state) {
|
||||
case State.ANSWERING:
|
||||
case State.AWARDING:
|
||||
setMessage(game.blackCard.replace(/_+/g, 'blank'));
|
||||
break;
|
||||
case State.AWARDED:
|
||||
case State.FINISHED: {
|
||||
const text = game.blackCard;
|
||||
let utterance = '';
|
||||
const blanksCount = text.split('_').length - 1;
|
||||
if (0 === blanksCount) {
|
||||
utterance += `${text} ${game.answers[game.winner[0]][0]}`;
|
||||
}
|
||||
else {
|
||||
utterance += game.answers[game.winner[0]]
|
||||
.reduce((r, text) => r.replace(/_+/, text), text);
|
||||
}
|
||||
utterance += '.\n';
|
||||
utterance += `gg ${game.players[game.winner[1]].name}`;
|
||||
setMessage(utterance);
|
||||
break;
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [game.state, setMessage]);
|
||||
useEffect(() => {
|
||||
if (isMuted) {
|
||||
window.speechSynthesis?.cancel();
|
||||
}
|
||||
else {
|
||||
window.speechSynthesis?.speak(new SpeechSynthesisUtterance(message));
|
||||
}
|
||||
}, [isMuted, message]);
|
||||
// Mutation.
|
||||
useEffect(() => {
|
||||
function onResize() {
|
||||
const em = parseFloat(getComputedStyle(document.body).fontSize);
|
||||
game.showUnread = !chatOpenTuple[0] && window.innerWidth < 80 * em;
|
||||
}
|
||||
onResize();
|
||||
window.addEventListener('resize', onResize);
|
||||
return () => {
|
||||
window.removeEventListener('resize', onResize);
|
||||
};
|
||||
}, [game, chatOpenTuple]);
|
||||
useEffect(() => {
|
||||
function handler(event) {
|
||||
for (const action of JSON.parse(event.data)) {
|
||||
const {state: lastState} = game;
|
||||
Game.mutateJson(game, action);
|
||||
switch (action.type) {
|
||||
case 'destroy': {
|
||||
navigate('/');
|
||||
return;
|
||||
}
|
||||
case 'message':
|
||||
if (game.showUnread) {
|
||||
unreadTuple[1](unreadTuple[0] + 1);
|
||||
}
|
||||
break;
|
||||
case 'state':
|
||||
if (![game.state, lastState].includes(State.PAUSED)) {
|
||||
setSelection([]);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
forceRender(Math.random());
|
||||
}
|
||||
let eventSource = new EventSource(`/play/${gameKey}/actions`);
|
||||
eventSource.addEventListener('action', handler);
|
||||
eventSource.addEventListener('error', () => {
|
||||
window.location.reload();
|
||||
});
|
||||
return () => {
|
||||
eventSource.removeEventListener('action', handler);
|
||||
eventSource.close();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [gameKey, navigate]);
|
||||
const Component = session.id ? Components[game.state] : NeedsCookie;
|
||||
return (
|
||||
<SessionContext.Provider value={session}>
|
||||
<GameContext.Provider value={game}>
|
||||
<SelectionContext.Provider value={selectionTuple}>
|
||||
<div className={styles.play}>
|
||||
<Players isOpen={chatOpenTuple[0]} />
|
||||
<div className={styles['black-and-white']}>
|
||||
<div className={styles['black-wrapper']}>
|
||||
<Bar
|
||||
mutedTuple={mutedTuple}
|
||||
chatOpenTuple={chatOpenTuple}
|
||||
gameKey={gameKey}
|
||||
unreadTuple={unreadTuple}
|
||||
/>
|
||||
<div className={styles.black}><Component.Black /></div>
|
||||
</div>
|
||||
<div className={styles['white-wrapper']}>
|
||||
<div className={styles.white}><Component.White /></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SelectionContext.Provider>
|
||||
</GameContext.Provider>
|
||||
</SessionContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
Play.defaultProps = {};
|
||||
|
||||
Play.propTypes = {};
|
||||
|
||||
Play.displayName = 'Play';
|
||||
|
||||
export default Play;
|
19
app/routes/play.$gameKey/status/answering/black.jsx
Normal file
19
app/routes/play.$gameKey/status/answering/black.jsx
Normal file
|
@ -0,0 +1,19 @@
|
|||
import BlackCardText from '#black-card-text';
|
||||
|
||||
import {useSelection} from '#hooks';
|
||||
import {useGame, useSession} from '#hooks';
|
||||
|
||||
function Answering() {
|
||||
const [selection] = useSelection();
|
||||
const game = useGame();
|
||||
const session = useSession();
|
||||
return <BlackCardText answers={selection.map((answer) => game.players[session.id].cards[answer])} />;
|
||||
}
|
||||
|
||||
Answering.defaultProps = {};
|
||||
|
||||
Answering.propTypes = {};
|
||||
|
||||
Answering.displayName = 'Answering';
|
||||
|
||||
export default Answering;
|
2
app/routes/play.$gameKey/status/answering/index.js
Normal file
2
app/routes/play.$gameKey/status/answering/index.js
Normal file
|
@ -0,0 +1,2 @@
|
|||
export {default as Black} from './black';
|
||||
export {default as White} from './white';
|
39
app/routes/play.$gameKey/status/answering/index.module.css
Normal file
39
app/routes/play.$gameKey/status/answering/index.module.css
Normal file
|
@ -0,0 +1,39 @@
|
|||
.info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.answers {
|
||||
height: 85%;
|
||||
justify-content: space-around;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.padded {
|
||||
height: 100%;
|
||||
padding: 4vh 4vw;
|
||||
}
|
||||
|
||||
.title {
|
||||
color: #555;
|
||||
font-family: Smack;
|
||||
font-size: 2.25vw;
|
||||
height: 15%;
|
||||
padding: 0.25em;
|
||||
text-align: center;
|
||||
&.bigger {
|
||||
height: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
.name {
|
||||
color: #d68030;
|
||||
font-family: Strike;
|
||||
}
|
||||
|
||||
.normal {
|
||||
color: #888;
|
||||
font-family: Shark;
|
||||
}
|
59
app/routes/play.$gameKey/status/answering/white.jsx
Normal file
59
app/routes/play.$gameKey/status/answering/white.jsx
Normal file
|
@ -0,0 +1,59 @@
|
|||
import Answers from '#answers';
|
||||
import FluidText from '#fluid-text';
|
||||
import {useGame, useSession} from '#hooks';
|
||||
|
||||
import styles from './index.module.css';
|
||||
|
||||
function Answering() {
|
||||
const game = useGame();
|
||||
const session = useSession();
|
||||
if (game.czar === session.id) {
|
||||
return (
|
||||
<div className={styles.info}>
|
||||
<div className={styles.padded}>
|
||||
<div className={styles.answers}>
|
||||
<FluidText>
|
||||
<span className={styles.name}>You</span>
|
||||
{' '}
|
||||
<span className={styles.normal}>are the</span>
|
||||
{' '}
|
||||
terrible
|
||||
</FluidText>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.title}><FluidText>After the others submit, you will choose the victor.</FluidText></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (game.players[session.id].answer) {
|
||||
return (
|
||||
<div className={styles.info}>
|
||||
<div className={[styles.title, styles.bigger].join(' ')}><FluidText>After the others submit, a victor will be chosen.</FluidText></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const count = Math.max(1, game.blackCard.split('_').length - 1);
|
||||
return (
|
||||
<div className={styles.info}>
|
||||
<div className={styles.answers}>
|
||||
<Answers
|
||||
choices={game.players[session.id].cards}
|
||||
count={count}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.title}>
|
||||
<FluidText>
|
||||
<div>
|
||||
<span className={styles.name}>{game.players[game.czar].name}</span>
|
||||
{' '}
|
||||
<span className={styles.normal}>will decide your fate</span>
|
||||
</div>
|
||||
</FluidText>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Answering.displayName = 'Answering';
|
||||
|
||||
export default Answering;
|
15
app/routes/play.$gameKey/status/awarded/black.jsx
Normal file
15
app/routes/play.$gameKey/status/awarded/black.jsx
Normal file
|
@ -0,0 +1,15 @@
|
|||
import BlackCardText from '#black-card-text';
|
||||
import {useGame} from '#hooks';
|
||||
|
||||
function Awarded() {
|
||||
const game = useGame();
|
||||
return <BlackCardText answers={game.answers[game.winner[0]]} />;
|
||||
}
|
||||
|
||||
Awarded.defaultProps = {};
|
||||
|
||||
Awarded.propTypes = {};
|
||||
|
||||
Awarded.displayName = 'Awarded';
|
||||
|
||||
export default Awarded;
|
2
app/routes/play.$gameKey/status/awarded/index.js
Normal file
2
app/routes/play.$gameKey/status/awarded/index.js
Normal file
|
@ -0,0 +1,2 @@
|
|||
export {default as Black} from './black';
|
||||
export {default as White} from './white';
|
14
app/routes/play.$gameKey/status/awarded/index.module.css
Normal file
14
app/routes/play.$gameKey/status/awarded/index.module.css
Normal file
|
@ -0,0 +1,14 @@
|
|||
.awarded {
|
||||
font-size: 5vw;
|
||||
height: 100%;
|
||||
padding: 5vh 10vw;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.winner {
|
||||
color: #d68030;
|
||||
display: inline-block;
|
||||
font-family: Strike;
|
||||
}
|
||||
|
26
app/routes/play.$gameKey/status/awarded/white.jsx
Normal file
26
app/routes/play.$gameKey/status/awarded/white.jsx
Normal file
|
@ -0,0 +1,26 @@
|
|||
import {useGame} from '#hooks';
|
||||
|
||||
import FluidText from '#fluid-text';
|
||||
|
||||
import styles from './index.module.css';
|
||||
|
||||
function Awarded() {
|
||||
const game = useGame();
|
||||
return (
|
||||
<div className={styles.awarded}>
|
||||
<FluidText>
|
||||
<span className={styles.winner}>{game.players[game.winner[1]].name}</span>
|
||||
{' '}
|
||||
won!
|
||||
</FluidText>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Awarded.defaultProps = {};
|
||||
|
||||
Awarded.propTypes = {};
|
||||
|
||||
Awarded.displayName = 'Awarded';
|
||||
|
||||
export default Awarded;
|
16
app/routes/play.$gameKey/status/awarding/black.jsx
Normal file
16
app/routes/play.$gameKey/status/awarding/black.jsx
Normal file
|
@ -0,0 +1,16 @@
|
|||
import BlackCardText from '#black-card-text';
|
||||
import {useGame, useSelection} from '#hooks';
|
||||
|
||||
function Awarding() {
|
||||
const [selection] = useSelection();
|
||||
const game = useGame();
|
||||
return <BlackCardText answers={game.answers[selection[0]]} />;
|
||||
}
|
||||
|
||||
Awarding.defaultProps = {};
|
||||
|
||||
Awarding.propTypes = {};
|
||||
|
||||
Awarding.displayName = 'Awarding';
|
||||
|
||||
export default Awarding;
|
2
app/routes/play.$gameKey/status/awarding/index.js
Normal file
2
app/routes/play.$gameKey/status/awarding/index.js
Normal file
|
@ -0,0 +1,2 @@
|
|||
export {default as Black} from './black';
|
||||
export {default as White} from './white';
|
18
app/routes/play.$gameKey/status/awarding/index.module.css
Normal file
18
app/routes/play.$gameKey/status/awarding/index.module.css
Normal file
|
@ -0,0 +1,18 @@
|
|||
.title {
|
||||
color: #777;
|
||||
font-size: 3.5vw;
|
||||
height: 15%;
|
||||
padding: 0 8vw;
|
||||
text-align: center;
|
||||
.name {
|
||||
color: #d68030;
|
||||
font-family: Strike;
|
||||
}
|
||||
}
|
||||
|
||||
.answers {
|
||||
align-content: flex-start;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
height: 85%;
|
||||
}
|
67
app/routes/play.$gameKey/status/awarding/white.jsx
Normal file
67
app/routes/play.$gameKey/status/awarding/white.jsx
Normal file
|
@ -0,0 +1,67 @@
|
|||
import Answers from '#answers';
|
||||
import FluidText from '#fluid-text';
|
||||
import {useGame, useSession} from '#hooks';
|
||||
|
||||
import styles from './index.module.css';
|
||||
|
||||
function Awarding() {
|
||||
const game = useGame();
|
||||
const session = useSession();
|
||||
const czarName = game.players[game.czar].name;
|
||||
const isCzar = game.czar === session.id;
|
||||
let key = 0;
|
||||
const choices = game.answers
|
||||
.map((answer) => (
|
||||
<>
|
||||
{answer.reduce(
|
||||
(r, text) => [
|
||||
...r,
|
||||
...(r.length > 0 ? [<span className={styles.separator} key={key++}> / </span>] : []),
|
||||
<span key={key++}>{text}</span>,
|
||||
],
|
||||
[],
|
||||
)}
|
||||
</>
|
||||
));
|
||||
return (
|
||||
<>
|
||||
<div className={styles.answers}>
|
||||
<Answers
|
||||
choices={choices}
|
||||
count={1}
|
||||
className={isCzar ? '' : styles.noselect}
|
||||
disabled={!isCzar}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.title}>
|
||||
<FluidText>
|
||||
{
|
||||
isCzar
|
||||
? (
|
||||
<div>
|
||||
Choose
|
||||
{' '}
|
||||
<span className={styles.normal}>the</span>
|
||||
{' '}
|
||||
<span className={styles.name}>victor</span>
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<div>
|
||||
<span className={styles.name}>{czarName}</span>
|
||||
{' '}
|
||||
<span className={styles.normal}>is choosing the</span>
|
||||
{' '}
|
||||
victor
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</FluidText>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Awarding.displayName = 'Awarding';
|
||||
|
||||
export default Awarding;
|
25
app/routes/play.$gameKey/status/finished/black.jsx
Normal file
25
app/routes/play.$gameKey/status/finished/black.jsx
Normal file
|
@ -0,0 +1,25 @@
|
|||
import FluidText from '#fluid-text';
|
||||
import {useGame} from '#hooks';
|
||||
|
||||
import styles from './index.module.css';
|
||||
|
||||
function Finished() {
|
||||
const game = useGame();
|
||||
return (
|
||||
<div className={styles.winnerWrapper}>
|
||||
<FluidText>
|
||||
<span className={styles.winner}>{game.players[game.winner[1]].name}</span>
|
||||
{' '}
|
||||
won!
|
||||
</FluidText>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Finished.defaultProps = {};
|
||||
|
||||
Finished.propTypes = {};
|
||||
|
||||
Finished.displayName = 'Finished';
|
||||
|
||||
export default Finished;
|
2
app/routes/play.$gameKey/status/finished/index.js
Normal file
2
app/routes/play.$gameKey/status/finished/index.js
Normal file
|
@ -0,0 +1,2 @@
|
|||
export {default as Black} from './black';
|
||||
export {default as White} from './white';
|
49
app/routes/play.$gameKey/status/finished/index.module.css
Normal file
49
app/routes/play.$gameKey/status/finished/index.module.css
Normal file
|
@ -0,0 +1,49 @@
|
|||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.start {
|
||||
width: 80%;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.text {
|
||||
padding: 4vh 4vw;
|
||||
}
|
||||
|
||||
.winnerWrapper {
|
||||
height: 100%;
|
||||
padding: 0 8vw;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.winner {
|
||||
color: #d68030;
|
||||
font-family: Strike;
|
||||
}
|
||||
|
||||
.message {
|
||||
height: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.first {
|
||||
height: 60%;
|
||||
padding: 2vh 2vw;
|
||||
}
|
||||
|
||||
.owner {
|
||||
color: #d68030;
|
||||
font-family: Strike;
|
||||
}
|
||||
|
||||
.second {
|
||||
height: 30%;
|
||||
padding: 2vh 8vw;
|
||||
text-underline-offset: 0.75em;
|
||||
}
|
||||
|
61
app/routes/play.$gameKey/status/finished/white.jsx
Normal file
61
app/routes/play.$gameKey/status/finished/white.jsx
Normal file
|
@ -0,0 +1,61 @@
|
|||
import {Link, useFetcher} from '@remix-run/react';
|
||||
|
||||
import FluidText from '#fluid-text';
|
||||
import {useGame, useIsHydrated, useSession} from '#hooks';
|
||||
|
||||
import styles from './index.module.css';
|
||||
|
||||
function Finished() {
|
||||
const fetcher = useFetcher({key: 'finished'});
|
||||
const game = useGame();
|
||||
const isHydrated = useIsHydrated();
|
||||
const session = useSession();
|
||||
return game.owner === session.id
|
||||
? (
|
||||
<fetcher.Form
|
||||
className={styles.form}
|
||||
method="put"
|
||||
>
|
||||
{!isHydrated && (
|
||||
<input name="redirect" type="hidden" />
|
||||
)}
|
||||
<button
|
||||
aria-label="Restart the game"
|
||||
className={styles.start}
|
||||
name="action"
|
||||
type="submit"
|
||||
value="start"
|
||||
>
|
||||
{/* hi */}
|
||||
<FluidText><span className={styles.text}>Restart</span></FluidText>
|
||||
</button>
|
||||
</fetcher.Form>
|
||||
)
|
||||
: (
|
||||
<div className={styles.message}>
|
||||
<div className={styles.first}>
|
||||
<FluidText>
|
||||
Harass
|
||||
{' '}
|
||||
<span className={styles.owner}>{game.players[game.owner].name}</span>
|
||||
{' '}
|
||||
to restart the game
|
||||
</FluidText>
|
||||
</div>
|
||||
<span className={styles.or}>or</span>
|
||||
<div className={styles.second}>
|
||||
<FluidText>
|
||||
<Link className={styles['create-your-own']} to="/create">create a game</Link>
|
||||
</FluidText>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Finished.defaultProps = {};
|
||||
|
||||
Finished.propTypes = {};
|
||||
|
||||
Finished.displayName = 'Finished';
|
||||
|
||||
export default Finished;
|
54
app/routes/play.$gameKey/status/paused/black.jsx
Normal file
54
app/routes/play.$gameKey/status/paused/black.jsx
Normal file
|
@ -0,0 +1,54 @@
|
|||
import {useFetcher} from '@remix-run/react';
|
||||
import FluidText from '#fluid-text';
|
||||
import {useGame, useSession} from '#hooks';
|
||||
|
||||
import styles from './index.module.css';
|
||||
|
||||
function Paused() {
|
||||
const fetcher = useFetcher({key: 'bots'});
|
||||
const game = useGame();
|
||||
const session = useSession();
|
||||
return (
|
||||
<div className={styles.paused}>
|
||||
<div
|
||||
className={
|
||||
[styles.message, game.owner === session.id && styles.half]
|
||||
.filter(Boolean).join(' ')
|
||||
}
|
||||
>
|
||||
<FluidText>
|
||||
Paused!
|
||||
</FluidText>
|
||||
</div>
|
||||
{game.owner === session.id && (
|
||||
<div className={[styles.message, styles.half].join(' ')}>
|
||||
<div className={styles.label}>
|
||||
<FluidText>
|
||||
You may add some bots to keep the game going!
|
||||
</FluidText>
|
||||
</div>
|
||||
<div className={styles.form}>
|
||||
<fetcher.Form method="put">
|
||||
<button
|
||||
name="action"
|
||||
type="submit"
|
||||
value="bots"
|
||||
>
|
||||
add some bots
|
||||
</button>
|
||||
</fetcher.Form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Paused.defaultProps = {};
|
||||
|
||||
Paused.propTypes = {};
|
||||
|
||||
Paused.displayName = 'Paused';
|
||||
|
||||
export default Paused;
|
2
app/routes/play.$gameKey/status/paused/index.js
Normal file
2
app/routes/play.$gameKey/status/paused/index.js
Normal file
|
@ -0,0 +1,2 @@
|
|||
export {default as Black} from './black';
|
||||
export {default as White} from './white';
|
39
app/routes/play.$gameKey/status/paused/index.module.css
Normal file
39
app/routes/play.$gameKey/status/paused/index.module.css
Normal file
|
@ -0,0 +1,39 @@
|
|||
.paused {
|
||||
text-align: center;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.paused form {
|
||||
line-height: normal;
|
||||
}
|
||||
|
||||
.message {
|
||||
height: 100%;
|
||||
padding: 2vh 8vw;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.half {
|
||||
height: 50%;
|
||||
}
|
||||
|
||||
.label {
|
||||
height: 40%;
|
||||
}
|
||||
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
height: 50%;
|
||||
margin: 2vh 0;
|
||||
button {
|
||||
background: #222;
|
||||
border: 1px solid #777;
|
||||
color: #fa873a;
|
||||
padding: 0.5em;
|
||||
font-size: 2em;
|
||||
}
|
||||
}
|
||||
|
19
app/routes/play.$gameKey/status/paused/white.jsx
Normal file
19
app/routes/play.$gameKey/status/paused/white.jsx
Normal file
|
@ -0,0 +1,19 @@
|
|||
import FluidText from '#fluid-text';
|
||||
|
||||
import styles from './index.module.css';
|
||||
|
||||
function Paused() {
|
||||
return (
|
||||
<div className={styles.message}>
|
||||
<FluidText>There are not enough active players to continue the game!</FluidText>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Paused.defaultProps = {};
|
||||
|
||||
Paused.propTypes = {};
|
||||
|
||||
Paused.displayName = 'Paused';
|
||||
|
||||
export default Paused;
|
74
app/routes/play.$gameKey/status/starting/black.jsx
Normal file
74
app/routes/play.$gameKey/status/starting/black.jsx
Normal file
|
@ -0,0 +1,74 @@
|
|||
import {useFetcher, useParams} from '@remix-run/react';
|
||||
|
||||
import FluidText from '#fluid-text';
|
||||
import {Presence} from '#state/globals';
|
||||
import {useGame, useIsHydrated, useSession} from '#hooks';
|
||||
|
||||
import styles from './black.module.css';
|
||||
|
||||
function Starting() {
|
||||
const {gameKey} = useParams();
|
||||
const fetcher = useFetcher({key: 'starting'});
|
||||
const game = useGame();
|
||||
const isHydrated = useIsHydrated();
|
||||
const session = useSession();
|
||||
const enoughPlayersToStart = 2 < Object.entries(game.players)
|
||||
.filter(([, {presence}]) => Presence.INACTIVE !== presence)
|
||||
.length;
|
||||
const isOwner = session.id === game.owner;
|
||||
const ownerName = game.players[game.owner].name;
|
||||
return (
|
||||
<div className={styles.starting}>
|
||||
{
|
||||
enoughPlayersToStart
|
||||
? (
|
||||
isOwner
|
||||
? (
|
||||
<div className={styles['lets-go']}>
|
||||
<div className={styles.excuse}>LET'S GO</div>
|
||||
<fetcher.Form
|
||||
className={styles.start}
|
||||
method="put"
|
||||
>
|
||||
{!isHydrated && (
|
||||
<input name="redirect" type="hidden" />
|
||||
)}
|
||||
<input name="game" type="hidden" value={gameKey} />
|
||||
<button
|
||||
aria-label="Start the game"
|
||||
name="action"
|
||||
type="submit"
|
||||
value="start"
|
||||
>
|
||||
<div className={styles.text}>Start</div>
|
||||
</button>
|
||||
</fetcher.Form>
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<FluidText className={styles.waiting}>
|
||||
<div>
|
||||
Waiting for
|
||||
{' '}
|
||||
<span className={styles.owner}>{ownerName}</span>
|
||||
{' '}
|
||||
to start!
|
||||
</div>
|
||||
</FluidText>
|
||||
)
|
||||
)
|
||||
: (
|
||||
<div className={styles.excuse}>Go get more players</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Starting.defaultProps = {};
|
||||
|
||||
Starting.propTypes = {};
|
||||
|
||||
Starting.displayName = 'Starting';
|
||||
|
||||
export default Starting;
|
88
app/routes/play.$gameKey/status/starting/black.module.css
Normal file
88
app/routes/play.$gameKey/status/starting/black.module.css
Normal file
|
@ -0,0 +1,88 @@
|
|||
.owner {
|
||||
color: #d68030;
|
||||
font-family: Strike;
|
||||
}
|
||||
|
||||
.excuse {
|
||||
color: #d68030;
|
||||
display: inline-block;
|
||||
padding: 3vh 3vw;
|
||||
font-family: Strike;
|
||||
font-size: 8vw;
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.starting {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.lets-go {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-size: 12vw;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
@media(min-width: 64em) {
|
||||
flex-direction: row;
|
||||
font-size: 6.25vw;
|
||||
}
|
||||
}
|
||||
|
||||
.excuse, .start {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.excuse {
|
||||
display: flex;
|
||||
> div {
|
||||
display: inline-table;
|
||||
padding: 3vh 3vw;
|
||||
width: 90%;
|
||||
}
|
||||
}
|
||||
|
||||
.start {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
@media(min-width: 64em) {
|
||||
height: 100%;
|
||||
width: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
.start button {
|
||||
background-color: #222;
|
||||
border: 1px solid black;
|
||||
border-radius: 5px;
|
||||
color: #f64030;
|
||||
font-size: inherit;
|
||||
text-transform: uppercase;
|
||||
width: 100%;
|
||||
|
||||
&:hover {
|
||||
background-color: #272727;
|
||||
color: #fa7a6e;
|
||||
}
|
||||
|
||||
.text {
|
||||
animation: hint 1s infinite;
|
||||
font-family: Strike;
|
||||
padding: 2vw;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes hint {
|
||||
from { opacity: 0.5; }
|
||||
50% { opacity: 1; }
|
||||
to { opacity: 0.5; }
|
||||
}
|
||||
|
||||
.waiting {
|
||||
font-size: 8vh;
|
||||
}
|
2
app/routes/play.$gameKey/status/starting/index.js
Normal file
2
app/routes/play.$gameKey/status/starting/index.js
Normal file
|
@ -0,0 +1,2 @@
|
|||
export {default as Black} from './black';
|
||||
export {default as White} from './white';
|
27
app/routes/play.$gameKey/status/starting/white.jsx
Normal file
27
app/routes/play.$gameKey/status/starting/white.jsx
Normal file
|
@ -0,0 +1,27 @@
|
|||
import {Link, useParams} from '@remix-run/react';
|
||||
|
||||
import FluidText from '#fluid-text';
|
||||
import styles from './white.module.css';
|
||||
|
||||
function Starting() {
|
||||
const {gameKey} = useParams();
|
||||
return (
|
||||
<div className={styles.info}>
|
||||
<div className={styles.description}>Share the code</div>
|
||||
<Link
|
||||
className={styles.destination}
|
||||
to={`/play/${gameKey}`}
|
||||
>
|
||||
<span className={styles['game-key']}>{gameKey}</span>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Starting.defaultProps = {};
|
||||
|
||||
Starting.propTypes = {};
|
||||
|
||||
Starting.displayName = 'Starting';
|
||||
|
||||
export default Starting;
|
54
app/routes/play.$gameKey/status/starting/white.module.css
Normal file
54
app/routes/play.$gameKey/status/starting/white.module.css
Normal file
|
@ -0,0 +1,54 @@
|
|||
.description {
|
||||
font-family: Smack;
|
||||
font-size: 8vw;
|
||||
@media(min-width: 64em) {
|
||||
font-size: 80px;
|
||||
}
|
||||
height: 20%;
|
||||
}
|
||||
|
||||
.destination {
|
||||
display: flex;
|
||||
height: 80%;
|
||||
flex-direction: column;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.game-key {
|
||||
color: #d68030;
|
||||
display: inline-table;
|
||||
letter-spacing: 1vw;
|
||||
font-family: Georgia, 'Times New Roman', Times, serif;
|
||||
font-size: 22vw;
|
||||
@media(min-width: 80em) {
|
||||
font-size: 15vw;
|
||||
}
|
||||
text-shadow:
|
||||
-1px 0 1px #111,
|
||||
1px 0 1px #111,
|
||||
0 1px 1px #111,
|
||||
0 -1px 1px #111,
|
||||
1px 1px 3px #000
|
||||
;
|
||||
}
|
||||
|
||||
.info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
justify-content: space-between;
|
||||
padding: 4vh 4vw;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
|
||||
.message {
|
||||
color: #666;
|
||||
height: 30%;
|
||||
}
|
||||
|
||||
.title {
|
||||
height: 30%;
|
||||
}
|
||||
}
|
3
app/routes/play.$gameKey_.chat-form/route.jsx
Normal file
3
app/routes/play.$gameKey_.chat-form/route.jsx
Normal file
|
@ -0,0 +1,3 @@
|
|||
import Form from '../play.$gameKey/players/chat/form';
|
||||
|
||||
export default Form;
|
142
app/state/cards.js
Normal file
142
app/state/cards.js
Normal file
|
@ -0,0 +1,142 @@
|
|||
function filter(type, text) {
|
||||
if (text.match(/insert name/i)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function sanitize(type, text) {
|
||||
// Trailing periods.
|
||||
if ('white' === type) {
|
||||
text = text.replace(/(.*)([^.]+)\.+$/, '$1$2');
|
||||
}
|
||||
// Teh dumb.
|
||||
text = text.replace('better then', 'better than');
|
||||
text = text.replace('worse then', 'worse than');
|
||||
text = text.replace('One in then', 'One in ten');
|
||||
text = text.replace('_ then _', '_ than _');
|
||||
text = text.replace('pount', 'pound');
|
||||
return text;
|
||||
}
|
||||
|
||||
function isCompactJson(json) {
|
||||
return !Array.isArray(json);
|
||||
}
|
||||
|
||||
function getCardText(card) {
|
||||
return 'string' === typeof card ? card : card.text;
|
||||
}
|
||||
|
||||
function readCompactJsonPacks(json) {
|
||||
const packs = [];
|
||||
for (let j = 0; j < json.packs.length; ++j) {
|
||||
const pack = json.packs[j];
|
||||
let tokens = undefined;
|
||||
const black = pack.black
|
||||
.filter((k) => filter('black', getCardText(json.black[k])))
|
||||
.map((k) => sanitize('black', getCardText(json.black[k])));
|
||||
black.forEach((text, i) => {
|
||||
const matches = text.match(/\[([^\]]+)\]/g);
|
||||
if (matches) {
|
||||
if (!tokens) {
|
||||
tokens = {};
|
||||
}
|
||||
if (!tokens[i]) {
|
||||
tokens[i] = {};
|
||||
}
|
||||
tokens[i].black = matches.map((match) => match.slice(1, -1));
|
||||
}
|
||||
});
|
||||
const white = pack.white
|
||||
.filter((k) => filter('white', getCardText(json.white[k])))
|
||||
.map((k) => sanitize('white', getCardText(json.white[k])));
|
||||
white.forEach((text, i) => {
|
||||
const matches = text.match(/\[([^\]]+)\]/g);
|
||||
if (matches) {
|
||||
if (!tokens) {
|
||||
tokens = {};
|
||||
}
|
||||
if (!tokens[i]) {
|
||||
tokens[i] = {};
|
||||
}
|
||||
tokens[i].white = matches.map((match) => match.slice(1, -1));
|
||||
}
|
||||
});
|
||||
packs.push({
|
||||
...pack,
|
||||
black,
|
||||
white,
|
||||
tokens,
|
||||
});
|
||||
}
|
||||
return packs;
|
||||
}
|
||||
|
||||
function normalizeToCompactJson(json) {
|
||||
const normalized = {
|
||||
black: [],
|
||||
white: [],
|
||||
packs: [],
|
||||
};
|
||||
for (let j = 0; j < json.length; ++j) {
|
||||
const pack = json[j];
|
||||
normalized.packs.push({
|
||||
...pack,
|
||||
black: pack.black.map((card) => normalized.black.push(getCardText(card)) - 1),
|
||||
white: pack.white.map((card) => normalized.white.push(getCardText(card)) - 1),
|
||||
});
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function readFullJsonPacks(json) {
|
||||
return readCompactJsonPacks(normalizeToCompactJson(json));
|
||||
}
|
||||
|
||||
export function readJsonPacks(json) {
|
||||
return isCompactJson(json)
|
||||
? readCompactJsonPacks(json)
|
||||
: readFullJsonPacks(json);
|
||||
}
|
||||
|
||||
export function shuffle(cards) {
|
||||
let currentIndex = cards.length, randomIndex;
|
||||
while (currentIndex > 0) {
|
||||
randomIndex = Math.floor(Math.random() * currentIndex);
|
||||
currentIndex--;
|
||||
[cards[currentIndex], cards[randomIndex]] = [cards[randomIndex], cards[currentIndex]];
|
||||
}
|
||||
return cards;
|
||||
}
|
||||
// Algorithm L(-ish :))
|
||||
export function sample(packs, k) {
|
||||
const n = packs.reduce((n, {cards: {length}}) => n + length, 0);
|
||||
const reservoir = Array(k);
|
||||
// Encode the "stream" into a jump table.
|
||||
const jump = [];
|
||||
let i;
|
||||
for (i = 0; i < packs.length; ++i) {
|
||||
jump[i] = 0 === i ? 0 : jump[i - 1] + packs[i - 1].cards.length;
|
||||
}
|
||||
let c = 0, d = 0;
|
||||
for (i = 0; i < k; ++i) {
|
||||
reservoir[i] = {pack: packs[d].id, card: c};
|
||||
if (++c === packs[d].cards.length) {
|
||||
c = 0;
|
||||
d += 1;
|
||||
}
|
||||
}
|
||||
let W = Math.exp(Math.log(Math.random()) / k);
|
||||
let j = d;
|
||||
while (i < n) {
|
||||
i += Math.floor(Math.log(Math.random()) / Math.log(1 - W)) + 1;
|
||||
while (j < jump.length - 1 && i >= jump[j + 1]) {
|
||||
j += 1;
|
||||
}
|
||||
if (i < n) {
|
||||
reservoir[Math.floor(Math.random() * k)] = {pack: packs[j].id, card: i - jump[j]};
|
||||
W = W * Math.exp(Math.log(Math.random()) / k);
|
||||
}
|
||||
}
|
||||
return reservoir;
|
||||
}
|
24
app/state/cards.test.js
Normal file
24
app/state/cards.test.js
Normal file
|
@ -0,0 +1,24 @@
|
|||
import {expect} from 'chai';
|
||||
|
||||
import {
|
||||
sample,
|
||||
} from './cards.js';
|
||||
|
||||
const D = 20;
|
||||
const N = 4000;
|
||||
|
||||
const packs = Array.from({length: D})
|
||||
.map((e, id) => ({
|
||||
id,
|
||||
cards: Array.from({length: N}, (e, i) => `${id} - ${i}`),
|
||||
}));
|
||||
|
||||
it('takes valid samples', () => {
|
||||
const reservoir = sample(packs, N);
|
||||
expect(reservoir.length)
|
||||
.to.equal(N);
|
||||
reservoir.forEach(({pack, card}) => {
|
||||
expect(packs[pack].cards[card])
|
||||
.to.equal(`${pack} - ${card}`);
|
||||
})
|
||||
});
|
20
app/state/cookie.server.js
Normal file
20
app/state/cookie.server.js
Normal file
|
@ -0,0 +1,20 @@
|
|||
import {createCookie} from '@remix-run/node';
|
||||
|
||||
import {singleton} from '#utils/singleton';
|
||||
|
||||
const jar = singleton('jar', {});
|
||||
|
||||
export async function mutate(request, key, mutator) {
|
||||
return serialize(key, await mutator(await parse(request, key)))
|
||||
}
|
||||
|
||||
export async function parse(request, key, defaultValue) {
|
||||
if (!jar[key]) {
|
||||
jar[key] = createCookie(key);
|
||||
}
|
||||
return (await jar[key].parse(request.headers.get('Cookie'))) || defaultValue;
|
||||
}
|
||||
|
||||
export async function serialize(key, data) {
|
||||
return jar[key].serialize(data);
|
||||
}
|
744
app/state/game.js
Normal file
744
app/state/game.js
Normal file
|
@ -0,0 +1,744 @@
|
|||
import {Delay, Presence, State} from '#state/globals';
|
||||
|
||||
import {sample, shuffle} from './cards.js';
|
||||
import namer from './namer.js';
|
||||
|
||||
class Player {
|
||||
constructor() {
|
||||
this.reset();
|
||||
}
|
||||
closeLongPolls() {
|
||||
const {longPolls} = this;
|
||||
this.longPolls = [];
|
||||
longPolls.forEach((longPoll) => {
|
||||
longPoll();
|
||||
});
|
||||
}
|
||||
emit(events) {
|
||||
for (const emitter of this.emitters) {
|
||||
emitter.emit(events);
|
||||
}
|
||||
}
|
||||
reset() {
|
||||
this.answer = [];
|
||||
this.cards = [];
|
||||
this.emitters = [];
|
||||
this.longPolls = [];
|
||||
this.name = '';
|
||||
this.score = 0;
|
||||
this.presence = Presence.ACTIVE;
|
||||
this.timeout = undefined;
|
||||
}
|
||||
set timeout(timeout) {
|
||||
if (this._timeout) {
|
||||
clearTimeout(this._timeout);
|
||||
}
|
||||
this._timeout = timeout;
|
||||
}
|
||||
toJSON() {
|
||||
return {
|
||||
answer: this.answer.length > 0,
|
||||
name: this.name,
|
||||
presence: this.presence,
|
||||
score: this.score,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class Game {
|
||||
static packs = [];
|
||||
static tokens = {};
|
||||
constructor() {
|
||||
this.reset();
|
||||
}
|
||||
acceptAward(answerer) {
|
||||
const answerers = Object.entries(this.answers);
|
||||
this.winner = [answerer, +answerers[answerer][0]];
|
||||
this.players[this.winner[1]].score++;
|
||||
const actions = [
|
||||
{type: 'winner', payload: this.winner},
|
||||
{
|
||||
type: 'score',
|
||||
payload: {
|
||||
id: +this.winner[1],
|
||||
score: this.players[this.winner[1]].score,
|
||||
},
|
||||
},
|
||||
];
|
||||
this.lastStateChange = Date.now();
|
||||
if (this.players[this.winner[1]].score === this.scoreToWin) {
|
||||
this.state = State.FINISHED;
|
||||
actions.push({type: 'timeout', payload: Delay.DESTRUCT});
|
||||
}
|
||||
else {
|
||||
this.state = State.AWARDED;
|
||||
actions.push({type: 'timeout', payload: Delay.AWARDED});
|
||||
}
|
||||
actions.push({type: 'state', payload: this.state});
|
||||
return actions;
|
||||
}
|
||||
addPlayer(id, name) {
|
||||
this.players[id] = new Player();
|
||||
this.players[id].name = name;
|
||||
return this.players[id];
|
||||
}
|
||||
addActionListener(type, listener) {
|
||||
if (!this.actionListeners[type]) {
|
||||
this.actionListeners[type] = [];
|
||||
}
|
||||
this.actionListeners[type].push(listener);
|
||||
}
|
||||
allocateBlackCards() {
|
||||
const {packs} = this.constructor;
|
||||
const worstCase = 1 + this.maxPlayers * (this.scoreToWin - 1);
|
||||
const pool = sample(this.packs.map((id) => ({id, cards: packs[id].black})), worstCase);
|
||||
for (let i = 0; i < pool.length; ++i) {
|
||||
this.blackCards[i] = pool[i].pack * 65536 + pool[i].card;
|
||||
}
|
||||
shuffle(this.blackCards);
|
||||
}
|
||||
allocateBlackReplacements() {
|
||||
const {packs, tokens} = this.constructor;
|
||||
for (let i = 0; i < this.blackCards.length; ++i) {
|
||||
const encoded = this.blackCards[i];
|
||||
const [pack, card] = [Math.floor(encoded / 65536), encoded % 65536];
|
||||
if (!packs[pack].tokens?.[card]?.black) {
|
||||
continue;
|
||||
}
|
||||
this.blackCardReplacements[encoded] = [];
|
||||
for (let j = 0; j < packs[pack].tokens[card].black.length; ++j) {
|
||||
const token = packs[pack].tokens[card].black[j];
|
||||
if (tokens[token]) {
|
||||
const sample = Math.floor(Math.random() * tokens[token].length);
|
||||
this.blackCardReplacements[encoded].push([token, sample]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
allocateCards() {
|
||||
this.allocateBlackCards();
|
||||
this.allocateBlackReplacements();
|
||||
this.allocateWhiteCards();
|
||||
this.allocateWhiteReplacements();
|
||||
}
|
||||
allocateWhiteCards() {
|
||||
const {packs} = this.constructor;
|
||||
// Derive the worst case number of white cards from the actual number of answers required from
|
||||
// the allocated black cards.
|
||||
const totalAnswers = this.blackCards
|
||||
// Very last round doesn't matter.
|
||||
.slice(0, -1)
|
||||
// Map to card text and accumulate the number of blanks.
|
||||
.map((encoded) => packs[Math.floor(encoded / 65536)].black[encoded % 65536])
|
||||
.reduce((total, text) => total + (text.match(/_/g)?.length || 1), 0)
|
||||
const total = this.packs.reduce((total, id) => total + packs[id].white.length, 0);
|
||||
const worstCase = this.maxPlayers * 9 + this.maxPlayers * 9 * totalAnswers;
|
||||
let pool;
|
||||
// Less than worst case? Allocate them all and keep a discard pile.
|
||||
if (total <= worstCase) {
|
||||
this.discard = [];
|
||||
pool = Array(total);
|
||||
let i = 0;
|
||||
for (let j = 0; j < this.packs.length; ++j) {
|
||||
const {white} = packs[this.packs[j]];
|
||||
for (let k = 0; k < white.length; ++k) {
|
||||
pool[i++] = {pack: this.packs[j], card: k};
|
||||
}
|
||||
}
|
||||
}
|
||||
// Sample a random distribution of the worst-case amount. No discard needed.
|
||||
else {
|
||||
pool = sample(this.packs.map((id) => ({id, cards: packs[id].white})), worstCase);
|
||||
}
|
||||
for (let i = 0; i < pool.length; ++i) {
|
||||
this.whiteCards[i] = pool[i].pack * 65536 + pool[i].card;
|
||||
}
|
||||
shuffle(this.whiteCards);
|
||||
}
|
||||
allocateWhiteReplacements() {
|
||||
const {packs, tokens} = this.constructor;
|
||||
for (let i = 0; i < this.whiteCards.length; ++i) {
|
||||
const encoded = this.whiteCards[i];
|
||||
const [pack, card] = [Math.floor(encoded / 65536), encoded % 65536];
|
||||
if (!packs[pack].tokens?.[card]?.white) {
|
||||
continue;
|
||||
}
|
||||
this.whiteCardReplacements[encoded] = [];
|
||||
for (let j = 0; j < packs[pack].tokens[card].white.length; ++j) {
|
||||
const token = packs[pack].tokens[card].white[j];
|
||||
if (tokens[token]) {
|
||||
const sample = Math.floor(Math.random() * tokens[token].length);
|
||||
this.whiteCardReplacements[encoded].push([token, sample]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
static check(formData) {
|
||||
const {packs} = this;
|
||||
const errors = {};
|
||||
const maxPlayers = +formData.get('maxPlayers');
|
||||
const scoreToWin = +formData.get('scoreToWin');
|
||||
// Check that we have enough cards for the worst case.
|
||||
const packIds = formData.getAll('packs').map((pack) => +pack);
|
||||
const blackWorstCase = 1 + maxPlayers * (scoreToWin - 1);
|
||||
if (blackWorstCase > packIds.reduce((total, id) => total + packs[id].black.length, 0)) {
|
||||
errors.black = 'Not enough black cards, select more packs!';
|
||||
}
|
||||
const whiteWorstCase = maxPlayers * 9;
|
||||
if (whiteWorstCase > packIds.reduce((total, id) => total + packs[id].white.length, 0)) {
|
||||
errors.white = 'Not enough white cards, select more packs!';
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
checkAnswers() {
|
||||
const actions = [];
|
||||
const players = Object.entries(this.players)
|
||||
.filter(([, {presence}]) => presence === Presence.ACTIVE)
|
||||
.filter(([id]) => +id !== this.czar);
|
||||
if (!players.every(([, {answer}]) => answer.length > 0)) {
|
||||
return actions;
|
||||
}
|
||||
this.answers = players.reduce(
|
||||
(answers, [id, player]) => ({
|
||||
...answers,
|
||||
[id]: player.answer
|
||||
.map((card) => [card, this.renderCard('white', player.cards[card])]),
|
||||
}),
|
||||
{},
|
||||
);
|
||||
players.forEach(([id, player]) => {
|
||||
player.answer = [];
|
||||
actions.push({type: 'answer', payload: [+id, false]});
|
||||
});
|
||||
actions.push({
|
||||
type: 'answers',
|
||||
payload: Object.values(this.answers)
|
||||
.map((answer) => answer.map(([, rendered]) => rendered)),
|
||||
});
|
||||
this.lastStateChange = Date.now();
|
||||
if (this.czar < 0 || Presence.INACTIVE === this.players[this.czar].presence) {
|
||||
actions.push(...this.forceAward());
|
||||
}
|
||||
else {
|
||||
this.state = State.AWARDING;
|
||||
actions.push(
|
||||
{type: 'state', payload: State.AWARDING},
|
||||
{type: 'timeout', payload: this.secondsPerRound},
|
||||
);
|
||||
}
|
||||
return actions;
|
||||
}
|
||||
dealWhiteCard() {
|
||||
if (0 === this.whiteCards.length) {
|
||||
this.whiteCards = this.discard;
|
||||
this.discard = [];
|
||||
shuffle(this.whiteCards);
|
||||
}
|
||||
return this.whiteCards.pop();
|
||||
}
|
||||
discardAnswers() {
|
||||
const discarding = [];
|
||||
Object.entries(this.answers)
|
||||
.forEach(([id, answer]) => {
|
||||
answer.forEach(([card]) => {
|
||||
if (this.discard) {
|
||||
discarding.push(this.players[id].cards[card]);
|
||||
}
|
||||
const whiteCard = this.dealWhiteCard();
|
||||
this.players[id].cards[card] = whiteCard;
|
||||
this.players[id].emit([{
|
||||
type: 'card',
|
||||
payload: [
|
||||
+id,
|
||||
+card,
|
||||
this.renderCard('white', whiteCard),
|
||||
],
|
||||
}]);
|
||||
});
|
||||
});
|
||||
if (this.discard) {
|
||||
this.discard.push(...discarding);
|
||||
}
|
||||
this.answers = {};
|
||||
return [{type: 'answers', payload: {}}];
|
||||
}
|
||||
emit(actions) {
|
||||
for (const id in this.players) {
|
||||
this.players[id].emit(actions);
|
||||
}
|
||||
for (const action of actions) {
|
||||
if (this.actionListeners[action.type]) {
|
||||
const listeners = [...this.actionListeners[action.type]];
|
||||
for (const listener of listeners) {
|
||||
listener(action);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
forceAnswers(players) {
|
||||
const count = Math.max(1, this.blackCard.split('_').length - 1);
|
||||
return players
|
||||
.map(([id, player]) => {
|
||||
player.answer = [];
|
||||
for (let i = 0; i < count; ++i) {
|
||||
player.answer.push(i);
|
||||
}
|
||||
return {type: 'answer', payload: [+id, true]};
|
||||
});
|
||||
}
|
||||
forceAward() {
|
||||
return this.acceptAward(Math.floor(Math.random() * Object.keys(this.answers).length));
|
||||
}
|
||||
handleAction(formData, session) {
|
||||
switch (formData.get('action')) {
|
||||
case 'answer': {
|
||||
switch (this.state) {
|
||||
case State.ANSWERING:
|
||||
this.players[session.id].answer = formData.getAll('selection');
|
||||
this.emit([
|
||||
{type: 'answer', payload: [session.id, true]},
|
||||
...this.checkAnswers(),
|
||||
]);
|
||||
break;
|
||||
case State.AWARDING:
|
||||
this.emit(this.acceptAward(formData.get('selection')));
|
||||
break;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'bots': {
|
||||
if (State.PAUSED !== this.state) {
|
||||
throw new Response('', {status: 400});
|
||||
}
|
||||
const activePlayers = Object.entries(this.players)
|
||||
.filter(([, {presence}]) => presence === Presence.ACTIVE);
|
||||
const needed = 3 - activePlayers.length;
|
||||
if (needed <= 0) {
|
||||
throw new Response('', {status: 400});
|
||||
}
|
||||
this.lastStateChange = Date.now();
|
||||
this.state = State.ANSWERING;
|
||||
const actions = [
|
||||
{type: 'state', payload: this.state},
|
||||
{type: 'timeout', payload: this.secondsPerRound},
|
||||
];
|
||||
const bots = [];
|
||||
let id = Math.min(0, activePlayers.reduce((lowest, [id]) => Math.min(lowest, id), 0));
|
||||
for (let i = 1; i <= needed; ++i) {
|
||||
const bot = this.addPlayer(--id, namer());
|
||||
for (let i = 0; i < 9; ++i) {
|
||||
bot.cards.push(this.dealWhiteCard());
|
||||
}
|
||||
bots.push([id, bot]);
|
||||
actions.push(
|
||||
{type: 'joined', payload: {id, player: bot}},
|
||||
);
|
||||
}
|
||||
actions.push(
|
||||
...this.forceAnswers(bots),
|
||||
...this.checkAnswers(),
|
||||
);
|
||||
this.emit(actions);
|
||||
break;
|
||||
}
|
||||
case 'message': {
|
||||
const message = formData.get('message');
|
||||
if ('' === message.trim() || message.length > 1024) {
|
||||
throw new Response('', {status: 400});
|
||||
}
|
||||
const payload = {
|
||||
key: parseFloat(formData.get('key')),
|
||||
owner: session.id,
|
||||
text: message,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
this.messages.push(payload);
|
||||
while (this.messages.length > 100) {
|
||||
this.messages.shift();
|
||||
}
|
||||
this.emit([{type: 'message', payload}]);
|
||||
break;
|
||||
}
|
||||
case 'rename': {
|
||||
const name = formData.get('name');
|
||||
if (0 === name.length) {
|
||||
return;
|
||||
}
|
||||
if (name.length > 24) {
|
||||
throw new Response('', {status: 400});
|
||||
}
|
||||
this.players[session.id].name = name;
|
||||
this.emit([{type: 'rename', payload: {id: session.id, name}}]);
|
||||
break;
|
||||
}
|
||||
case 'start': {
|
||||
if (![State.FINISHED, State.STARTING].includes(this.state)) {
|
||||
throw new Response('', {status: 400});
|
||||
}
|
||||
if (session.id !== this.owner) {
|
||||
throw new Response('', {status: 401});
|
||||
}
|
||||
this.allocateCards();
|
||||
this.answers = {};
|
||||
this.blackCard = this.renderCard('black', this.blackCards[0]);
|
||||
this.czar = +Object.keys(this.players)[1];
|
||||
this.lastStateChange = Date.now();
|
||||
this.state = State.ANSWERING;
|
||||
const actions = [];
|
||||
const bots = [];
|
||||
Object.entries(this.players)
|
||||
.forEach(([id, player]) => {
|
||||
player.score = 0;
|
||||
actions.push({type: 'score', payload: {id: +id, score: 0}});
|
||||
player.cards = [];
|
||||
for (let i = 0; i < 9; ++i) {
|
||||
player.cards.push(this.dealWhiteCard());
|
||||
}
|
||||
player.emit(player.cards.map((card, i) => ({
|
||||
type: 'card',
|
||||
payload: [
|
||||
+id,
|
||||
+i,
|
||||
this.renderCard('white', card),
|
||||
],
|
||||
})));
|
||||
if (+id < 0) {
|
||||
bots.push([id, player]);
|
||||
}
|
||||
});
|
||||
this.emit([
|
||||
...actions,
|
||||
...this.forceAnswers(bots),
|
||||
{type: 'answers', payload: {}},
|
||||
{type: 'black-card', payload: this.blackCard},
|
||||
{type: 'czar', payload: this.czar},
|
||||
{type: 'state', payload: this.state},
|
||||
{type: 'timeout', payload: this.secondsPerRound},
|
||||
]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
loaderData(session) {
|
||||
const json = this.toJSON();
|
||||
if (!session.id) {
|
||||
return json;
|
||||
}
|
||||
this.emit(
|
||||
this.sideEffectsForSession(session)
|
||||
.map((action) => {
|
||||
this.constructor.mutateJson(json, action);
|
||||
return action;
|
||||
}),
|
||||
);
|
||||
const player = this.players[session.id];
|
||||
json.players[session.id] = {
|
||||
...player.toJSON(),
|
||||
answer: player.answer.length > 0 && State.ANSWERING === this.state ? player.answer : false,
|
||||
cards: player.cards.map((card) => this.renderCard('white', card)),
|
||||
};
|
||||
return json;
|
||||
}
|
||||
static mutateJson(game, action) {
|
||||
const {type, payload} = action;
|
||||
switch (type) {
|
||||
case 'answer':
|
||||
game.players[payload[0]].answer = payload[1];
|
||||
break;
|
||||
case 'answers':
|
||||
game.answers = payload;
|
||||
break;
|
||||
case 'black-card':
|
||||
game.blackCard = payload;
|
||||
break;
|
||||
case 'card':
|
||||
game.players[payload[0]].cards[payload[1]] = payload[2];
|
||||
break;
|
||||
case 'czar':
|
||||
game.czar = payload;
|
||||
break;
|
||||
case 'destroy':
|
||||
game.destroyed = true;
|
||||
break;
|
||||
case 'joined': {
|
||||
const {id, player} = payload;
|
||||
game.players[id] = player;
|
||||
break;
|
||||
}
|
||||
case 'message': {
|
||||
const index = game.messages.findLastIndex(({key}) => key == payload.key);
|
||||
if (-1 === index) {
|
||||
game.messages.push(payload);
|
||||
}
|
||||
else {
|
||||
game.messages[index] = payload;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'owner':
|
||||
game.owner = payload;
|
||||
break;
|
||||
case 'presence':
|
||||
game.players[payload.id].presence = payload.presence;
|
||||
break;
|
||||
case 'remove':
|
||||
delete game.players[payload];
|
||||
break;
|
||||
case 'rename': {
|
||||
const {id, name} = payload;
|
||||
game.players[id].name = name;
|
||||
break;
|
||||
}
|
||||
case 'score':
|
||||
game.players[payload.id].score = payload.score;
|
||||
break;
|
||||
case 'state':
|
||||
game.state = payload;
|
||||
break;
|
||||
case 'timeout':
|
||||
// Little nudge for cache breaking.
|
||||
game.timeout = payload + (Math.random() * 0.001);
|
||||
break;
|
||||
case 'winner':
|
||||
game.winner = payload;
|
||||
break;
|
||||
}
|
||||
}
|
||||
removeActionListener(type, listener) {
|
||||
if (!this.actionListeners[type]) {
|
||||
return;
|
||||
}
|
||||
const listeners = this.actionListeners[type];
|
||||
listeners.splice(listeners.indexOf(listener), 1);
|
||||
}
|
||||
removePlayer(id) {
|
||||
this.emit([
|
||||
{type: 'remove', payload: id},
|
||||
]);
|
||||
(this.discard ? this.discard : this.whiteCards).push(...this.players[id].cards);
|
||||
this.players[id].reset();
|
||||
delete this.players[id];
|
||||
}
|
||||
renderCard(type, encoded) {
|
||||
const {packs, tokens} = this.constructor;
|
||||
let text = packs[Math.floor(encoded / 65536)][type][encoded % 65536];
|
||||
this[`${type}CardReplacements`][encoded]?.forEach(([token, replacement]) => {
|
||||
text = text.replace(`[${token}]`, tokens[token][replacement]);
|
||||
});
|
||||
return text;
|
||||
}
|
||||
reset() {
|
||||
this.actionListeners = {};
|
||||
this.answers = {};
|
||||
this.blackCard = '';
|
||||
this.blackCards = [];
|
||||
this.blackCardReplacements = [];
|
||||
this.czar = undefined;
|
||||
this.packs = [];
|
||||
this.lastStateChange = 0;
|
||||
this.maxPlayers = 0;
|
||||
this.messages = [];
|
||||
this.owner = undefined;
|
||||
for (const id in this.players) {
|
||||
this.players[id].reset();
|
||||
}
|
||||
this.players = {};
|
||||
this.scoreToWin = 0;
|
||||
this.secondsPerRound = 0;
|
||||
this.state = State.STARTING;
|
||||
this.whiteCards = [];
|
||||
this.whiteCardReplacements = [];
|
||||
this.winner = undefined;
|
||||
return this;
|
||||
}
|
||||
setPlayerInactive(id, remove) {
|
||||
const player = this.players[id];
|
||||
player.presence = Presence.INACTIVE;
|
||||
const actions = [
|
||||
{type: 'presence', payload: {id, presence: player.presence}},
|
||||
];
|
||||
if (this.owner === id) {
|
||||
const entry = Object.entries(this.players)
|
||||
.find(([other]) => other !== id && other > 0);
|
||||
if (entry) {
|
||||
this.owner = +entry[0];
|
||||
actions.push({type: 'owner', payload: this.owner});
|
||||
}
|
||||
}
|
||||
switch (this.state) {
|
||||
case State.AWARDING:
|
||||
if (this.czar === id) {
|
||||
actions.push(...this.forceAward());
|
||||
}
|
||||
break;
|
||||
case State.ANSWERING: {
|
||||
const activePlayers = Object.values(this.players)
|
||||
.filter(({presence}) => presence === Presence.ACTIVE);
|
||||
if (activePlayers.length >= 3) {
|
||||
actions.push(...this.checkAnswers());
|
||||
}
|
||||
else {
|
||||
this.state = State.PAUSED;
|
||||
this.lastStateChange = Date.now();
|
||||
this.emit([
|
||||
{type: 'state', payload: this.state},
|
||||
{type: 'timeout', payload: Delay.DESTRUCT},
|
||||
]);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
this.emit(actions);
|
||||
player.timeout = setTimeout(remove, Delay.REMOVED * 1000);
|
||||
}
|
||||
sideEffectsForSession(session) {
|
||||
const actions = [];
|
||||
let player = this.players[session.id];
|
||||
let joined = false;
|
||||
if (!player) {
|
||||
player = this.addPlayer(session.id, namer());
|
||||
if (![State.FINISHED, State.STARTING].includes(this.state)) {
|
||||
for (let i = 0; i < 9; ++i) {
|
||||
player.cards.push(this.dealWhiteCard());
|
||||
}
|
||||
}
|
||||
joined = true;
|
||||
}
|
||||
if (joined) {
|
||||
this.emit([{type: 'joined', payload: {id: session.id, player}}]);
|
||||
}
|
||||
if (player.presence != Presence.ACTIVE) {
|
||||
player.presence = Presence.ACTIVE;
|
||||
actions.push({type: 'presence', payload: {id: session.id, presence: player.presence}});
|
||||
}
|
||||
player.timeout = undefined;
|
||||
if (!this.players[this.owner] || this.players[this.owner].presence === Presence.INACTIVE) {
|
||||
this.owner = session.id;
|
||||
actions.push({type: 'owner', payload: this.owner});
|
||||
}
|
||||
if (State.PAUSED === this.state) {
|
||||
const activePlayers = Object.entries(this.players)
|
||||
.filter(([, {presence}]) => presence === Presence.ACTIVE);
|
||||
if (activePlayers.length >= 3) {
|
||||
if (!this.players[this.czar] || this.players[this.czar].presence !== Presence.ACTIVE) {
|
||||
const ids = activePlayers
|
||||
.map(([id]) => id)
|
||||
.map((id) => +id);
|
||||
this.czar = ids[Math.floor(Math.random() * ids.length)];
|
||||
actions.push({type: 'czar', payload: this.czar});
|
||||
}
|
||||
this.lastStateChange = Date.now();
|
||||
this.state = State.ANSWERING;
|
||||
actions.push(
|
||||
{type: 'state', payload: this.state},
|
||||
{type: 'timeout', payload: this.secondsPerRound},
|
||||
);
|
||||
}
|
||||
}
|
||||
return actions;
|
||||
}
|
||||
tick() {
|
||||
const sinceLast = (Date.now() - this.lastStateChange) / 1000;
|
||||
switch (this.state) {
|
||||
case State.FINISHED:
|
||||
case State.PAUSED:
|
||||
case State.STARTING:
|
||||
if (sinceLast >= Delay.DESTRUCT) {
|
||||
this.emit([{type: 'destroy'}]);
|
||||
this.reset();
|
||||
return false;
|
||||
}
|
||||
break;
|
||||
case State.ANSWERING:
|
||||
if (sinceLast >= this.secondsPerRound) {
|
||||
const actions = this.forceAnswers(
|
||||
Object.entries(this.players)
|
||||
.filter(([id, {answer}]) => (
|
||||
+id !== this.czar
|
||||
&& answer.length === 0
|
||||
)),
|
||||
);
|
||||
actions.push(...this.checkAnswers());
|
||||
this.emit(actions);
|
||||
}
|
||||
break;
|
||||
case State.AWARDING:
|
||||
if (sinceLast >= this.secondsPerRound) {
|
||||
this.emit(this.forceAward());
|
||||
}
|
||||
break;
|
||||
case State.AWARDED:
|
||||
if (sinceLast >= Delay.AWARDED) {
|
||||
this.lastStateChange = Date.now();
|
||||
this.state = State.ANSWERING;
|
||||
this.blackCards.shift();
|
||||
this.blackCard = this.renderCard('black', this.blackCards[0]);
|
||||
const actions = [
|
||||
{type: 'black-card', payload: this.blackCard},
|
||||
{type: 'state', payload: this.state},
|
||||
];
|
||||
actions.push(...this.discardAnswers());
|
||||
const activePlayers = Object.entries(this.players)
|
||||
.filter(([, {presence}]) => presence === Presence.ACTIVE);
|
||||
if (activePlayers.length >= 3) {
|
||||
const ids = activePlayers.map(([id]) => id).map((id) => +id);
|
||||
this.czar = ids[(ids.indexOf(this.czar) + 1) % ids.length];
|
||||
actions.push(...this.forceAnswers(Object.entries(this.players).filter(([id]) => (
|
||||
+id < 0
|
||||
&& this.czar !== +id
|
||||
))));
|
||||
actions.push(
|
||||
{type: 'czar', payload: this.czar},
|
||||
...this.checkAnswers(),
|
||||
{type: 'timeout', payload: this.secondsPerRound},
|
||||
);
|
||||
}
|
||||
else {
|
||||
this.state = State.PAUSED;
|
||||
actions.push(
|
||||
{type: 'state', payload: this.state},
|
||||
{type: 'timeout', payload: Delay.DESTRUCT},
|
||||
);
|
||||
}
|
||||
this.emit(actions);
|
||||
}
|
||||
break;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
timeoutToJSON() {
|
||||
let timeout;
|
||||
switch (this.state) {
|
||||
case State.FINISHED:
|
||||
case State.PAUSED:
|
||||
case State.STARTING:
|
||||
timeout = Delay.DESTRUCT - ((Date.now() - this.lastStateChange) / 1000);
|
||||
break;
|
||||
case State.ANSWERING:
|
||||
case State.AWARDING:
|
||||
timeout = this.secondsPerRound - ((Date.now() - this.lastStateChange) / 1000);
|
||||
break;
|
||||
case State.AWARDED:
|
||||
timeout = Delay.AWARDED - ((Date.now() - this.lastStateChange) / 1000);
|
||||
break;
|
||||
}
|
||||
return timeout;
|
||||
}
|
||||
// toWire
|
||||
toJSON() {
|
||||
return {
|
||||
answers: Object.values(this.answers)
|
||||
.map((answer) => answer.map(([, rendered]) => rendered)),
|
||||
blackCard: this.blackCard,
|
||||
czar: this.czar,
|
||||
messages: this.messages,
|
||||
owner: this.owner,
|
||||
players: Object.entries(this.players)
|
||||
.map(([id, player]) => [id, player.toJSON()])
|
||||
.reduce((players, [id, player]) => ({...players, [id]: player}), {}),
|
||||
state: this.state,
|
||||
timeout: this.timeoutToJSON(),
|
||||
winner: this.winner,
|
||||
};
|
||||
}
|
||||
}
|
255
app/state/game.server.js
Normal file
255
app/state/game.server.js
Normal file
|
@ -0,0 +1,255 @@
|
|||
import {PassThrough} from 'node:stream';
|
||||
|
||||
import {Delay, Presence, State} from '#state/globals';
|
||||
import {loadSession} from '#state/session';
|
||||
import {singleton} from '#utils/singleton';
|
||||
import {TopLevelLongPoll} from '#utils/top-level-long-poll';
|
||||
|
||||
import {unreadUpdateStyle} from '../routes/play.$gameKey/bar';
|
||||
import {numberUpdateStyle} from '../routes/play.$gameKey/bar/timeout/number';
|
||||
import {
|
||||
playerJoinedUpdateStyle,
|
||||
playerNameUpdateStyle,
|
||||
playerPresenceUpdateStyle,
|
||||
playerRemoveUpdateStyle,
|
||||
sortPlayers,
|
||||
} from '../routes/play.$gameKey/players/list';
|
||||
|
||||
import {Game} from './game.js';
|
||||
import namer from './namer.js';
|
||||
|
||||
const alphabet = 'ABCDEFHIJKLMNPQRSTUVWXYZ';
|
||||
|
||||
const free = singleton('free', [
|
||||
'BOOB', 'JUNK', 'BUTT', 'POOP', 'FART', 'CHIT', 'MEOW', 'WOOF', 'BORK', 'DERP', 'YAWN', 'ZOOM',
|
||||
'GEEK', 'BEEP', 'HONK', 'GROK', 'NERD', 'PLOP', 'YIKE', 'JAZZ', 'WINK', 'GLOP', 'HISS', 'ZEST',
|
||||
'JIVE', 'GAGA', 'FLUB', 'JEEP', 'OOPS', 'VIBE', 'ZING', 'FLIP', 'ZEAL', 'VAMP', 'YOGA', 'GOOP',
|
||||
'FIZZ', 'ZANY', 'DORK', 'FLAP', 'HUNK', 'YOWL', 'WISP', 'YAWP', 'DING', 'DONG', 'WANG', 'BURP',
|
||||
].reverse().map((gameKey) => [gameKey, new Game()]));
|
||||
|
||||
const games = singleton('games', new Map());
|
||||
|
||||
function createKey() {
|
||||
let gameKey;
|
||||
do {
|
||||
gameKey = '';
|
||||
for (let i = 0; i < 4; ++i) {
|
||||
gameKey += alphabet.charAt(Math.floor(Math.random() * alphabet.length));
|
||||
}
|
||||
} while (games.has(gameKey));
|
||||
return gameKey;
|
||||
}
|
||||
|
||||
export async function createGame(session, formData) {
|
||||
const [gameKey, game] = free.length > 0 ? free.pop() : [createKey(), new Game()];
|
||||
games.set(gameKey, game);
|
||||
game.lastStateChange = Date.now();
|
||||
game.maxPlayers = +formData.get('maxPlayers');
|
||||
game.owner = session.id;
|
||||
game.scoreToWin = +formData.get('scoreToWin');
|
||||
game.secondsPerRound = +formData.get('secondsPerRound');
|
||||
game.packs = formData.getAll('packs').map((pack) => +pack);
|
||||
const bots = +formData.get('bots');
|
||||
for (let i = 1; i <= bots; ++i) {
|
||||
game.addPlayer(-i, namer());
|
||||
}
|
||||
return gameKey;
|
||||
}
|
||||
|
||||
export function createGameServerLoop() {
|
||||
return setInterval(() => {
|
||||
const pruning = [];
|
||||
for (const [gameKey, game] of games.entries()) {
|
||||
if (!game.tick()) {
|
||||
pruning.push(gameKey);
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < pruning.length; ++i) {
|
||||
const gameKey = pruning[i];
|
||||
free.push([gameKey, games.get(gameKey)]);
|
||||
games.delete(gameKey);
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
export async function joinList() {
|
||||
const list = [];
|
||||
for (const [gameKey, game] of games.entries()) {
|
||||
const players = Object.entries(game.players);
|
||||
if (9 === players.length) {
|
||||
continue;
|
||||
}
|
||||
list.push({
|
||||
completed: (
|
||||
players.length > 0
|
||||
? (
|
||||
players
|
||||
.map(([, {score}]) => score)
|
||||
.toSorted((l, r) => l - r)
|
||||
.pop()
|
||||
/ game.scoreToWin
|
||||
)
|
||||
: 0
|
||||
),
|
||||
key: gameKey,
|
||||
playerCount: (
|
||||
players
|
||||
.filter(([, {presence}]) => Presence.ACTIVE === presence)
|
||||
.length
|
||||
),
|
||||
state: game.state,
|
||||
});
|
||||
if (100 === list.length) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
export async function loadGame(gameKey) {
|
||||
return games.get(gameKey);
|
||||
}
|
||||
|
||||
export async function requestBody(request) {
|
||||
const session = await loadSession(request);
|
||||
if (!session) {
|
||||
return new PassThrough();
|
||||
}
|
||||
const {pathname, searchParams} = new URL(request.url);
|
||||
const matches = pathname.match(/^\/play\/([A-Z]+)$/);
|
||||
return matches
|
||||
? new TopLevelLongPoll(async (send) => {
|
||||
const gameKey = matches[1];
|
||||
const game = await loadGame(gameKey);
|
||||
if (request.signal.aborted) return;
|
||||
const player = game.players[session.id];
|
||||
let resolve;
|
||||
const promise = new Promise((resolve_) => {
|
||||
resolve = resolve_;
|
||||
});
|
||||
let closed = false;
|
||||
let handle;
|
||||
function close() {
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
closed = true;
|
||||
if (handle) {
|
||||
clearTimeout(handle);
|
||||
}
|
||||
game.removeActionListener('destroy', refresh);
|
||||
game.removeActionListener('presence', presence);
|
||||
game.removeActionListener('joined', joined);
|
||||
game.removeActionListener('message', message);
|
||||
game.removeActionListener('remove', remove);
|
||||
game.removeActionListener('rename', rename);
|
||||
game.removeActionListener('state', refresh);
|
||||
request.signal.removeEventListener('abort', close);
|
||||
const index = player.longPolls.indexOf(close);
|
||||
if (-1 !== index) {
|
||||
player.longPolls.splice(index, 1);
|
||||
if (0 === player.longPolls.length) {
|
||||
player.timeout = startTimeout(gameKey, session);
|
||||
}
|
||||
}
|
||||
resolve();
|
||||
}
|
||||
const refresh = () => {
|
||||
send(`<meta http-equiv="refresh" content="0;URL=/play/${gameKey}" />`);
|
||||
close();
|
||||
}
|
||||
async function pumpTimeout() {
|
||||
let timeout = game.timeoutToJSON();
|
||||
const fractional = timeout < 10;
|
||||
let c = timeout;
|
||||
let m;
|
||||
if (fractional) {
|
||||
[c, m] = `${Math.max(0, timeout)}`.split('.');
|
||||
}
|
||||
send(numberUpdateStyle(c, m));
|
||||
handle = setTimeout(pumpTimeout, fractional ? 77 : 1_000);
|
||||
}
|
||||
let playerCount = 0;
|
||||
const playersRemoved = [];
|
||||
const playerMap = {};
|
||||
const players = Object.entries(game.players);
|
||||
players
|
||||
.sort(sortPlayers(game))
|
||||
.forEach(([id]) => {
|
||||
playerMap[id] = playerCount++;
|
||||
});
|
||||
function rename({payload}) {
|
||||
send(playerNameUpdateStyle(playerMap[payload.id], payload.name));
|
||||
}
|
||||
let activePlayers = players
|
||||
.filter(([, {presence}]) => presence === Presence.ACTIVE)
|
||||
.length;
|
||||
function presence({payload}) {
|
||||
if (Presence.ACTIVE === payload.presence) {
|
||||
activePlayers += 1;
|
||||
if (game.state === State.STARTING && 3 === activePlayers) {
|
||||
refresh();
|
||||
return;
|
||||
}
|
||||
}
|
||||
else {
|
||||
activePlayers -= 1;
|
||||
if (game.state === State.STARTING && 2 === activePlayers) {
|
||||
refresh();
|
||||
return;
|
||||
}
|
||||
}
|
||||
send(playerPresenceUpdateStyle(payload.presence, playerMap[payload.id]));
|
||||
}
|
||||
function joined({payload}) {
|
||||
activePlayers += 1;
|
||||
if (game.state === State.STARTING && 3 === activePlayers) {
|
||||
refresh();
|
||||
return;
|
||||
}
|
||||
if (!playerMap[payload.id]) {
|
||||
playerMap[payload.id] = 9 === playerCount ? playersRemoved.shift() : playerCount++;
|
||||
}
|
||||
send(playerJoinedUpdateStyle(playerMap[payload.id], payload.player));
|
||||
}
|
||||
function remove({payload}) {
|
||||
playersRemoved.push(payload);
|
||||
send(playerRemoveUpdateStyle(playerMap[payload]));
|
||||
}
|
||||
let unread = 0;
|
||||
function message({payload}) {
|
||||
if (searchParams.has('chat') || payload.owner === session.id) {
|
||||
return;
|
||||
}
|
||||
send(unreadUpdateStyle(++unread));
|
||||
}
|
||||
handle = pumpTimeout();
|
||||
request.signal.addEventListener('abort', close);
|
||||
player.longPolls.push(close);
|
||||
game.addActionListener('destroy', refresh);
|
||||
game.addActionListener('presence', presence);
|
||||
game.addActionListener('joined', joined);
|
||||
game.addActionListener('message', message);
|
||||
game.addActionListener('remove', remove);
|
||||
game.addActionListener('rename', rename);
|
||||
game.addActionListener('state', refresh);
|
||||
return promise;
|
||||
})
|
||||
: new PassThrough();
|
||||
}
|
||||
|
||||
export function startTimeout(gameKey, session) {
|
||||
return setTimeout(async () => {
|
||||
const game = await loadGame(gameKey);
|
||||
if (!game || !game.players[session.id]) {
|
||||
return;
|
||||
}
|
||||
game.setPlayerInactive(session.id, async () => {
|
||||
const game = await loadGame(gameKey);
|
||||
if (!game || !game.players[session.id]) {
|
||||
return;
|
||||
}
|
||||
game.removePlayer(session.id);
|
||||
});
|
||||
}, Delay.INACTIVE * 1000);
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user